ifcraftcorpus 1.1.0__py3-none-any.whl → 1.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. ifcraftcorpus/cli.py +54 -5
  2. ifcraftcorpus/embeddings.py +11 -7
  3. ifcraftcorpus/index.py +26 -4
  4. ifcraftcorpus/logging_utils.py +84 -0
  5. ifcraftcorpus/mcp_server.py +418 -22
  6. ifcraftcorpus/providers.py +4 -4
  7. ifcraftcorpus/search.py +60 -12
  8. {ifcraftcorpus-1.1.0.data → ifcraftcorpus-1.2.1.data}/data/share/ifcraftcorpus/corpus/agent-design/agent_prompt_engineering.md +183 -9
  9. ifcraftcorpus-1.2.1.data/data/share/ifcraftcorpus/subagents/README.md +198 -0
  10. ifcraftcorpus-1.2.1.data/data/share/ifcraftcorpus/subagents/if_genre_consultant.md +257 -0
  11. ifcraftcorpus-1.2.1.data/data/share/ifcraftcorpus/subagents/if_platform_advisor.md +306 -0
  12. ifcraftcorpus-1.2.1.data/data/share/ifcraftcorpus/subagents/if_prose_writer.md +187 -0
  13. ifcraftcorpus-1.2.1.data/data/share/ifcraftcorpus/subagents/if_quality_reviewer.md +245 -0
  14. ifcraftcorpus-1.2.1.data/data/share/ifcraftcorpus/subagents/if_story_architect.md +162 -0
  15. ifcraftcorpus-1.2.1.data/data/share/ifcraftcorpus/subagents/if_world_curator.md +280 -0
  16. {ifcraftcorpus-1.1.0.dist-info → ifcraftcorpus-1.2.1.dist-info}/METADATA +18 -1
  17. ifcraftcorpus-1.2.1.dist-info/RECORD +67 -0
  18. ifcraftcorpus-1.1.0.dist-info/RECORD +0 -59
  19. {ifcraftcorpus-1.1.0.data → ifcraftcorpus-1.2.1.data}/data/share/ifcraftcorpus/corpus/agent-design/multi_agent_patterns.md +0 -0
  20. {ifcraftcorpus-1.1.0.data → ifcraftcorpus-1.2.1.data}/data/share/ifcraftcorpus/corpus/audience-and-access/accessibility_guidelines.md +0 -0
  21. {ifcraftcorpus-1.1.0.data → ifcraftcorpus-1.2.1.data}/data/share/ifcraftcorpus/corpus/audience-and-access/audience_targeting.md +0 -0
  22. {ifcraftcorpus-1.1.0.data → ifcraftcorpus-1.2.1.data}/data/share/ifcraftcorpus/corpus/audience-and-access/localization_considerations.md +0 -0
  23. {ifcraftcorpus-1.1.0.data → ifcraftcorpus-1.2.1.data}/data/share/ifcraftcorpus/corpus/craft-foundations/audio_visual_integration.md +0 -0
  24. {ifcraftcorpus-1.1.0.data → ifcraftcorpus-1.2.1.data}/data/share/ifcraftcorpus/corpus/craft-foundations/collaborative_if_writing.md +0 -0
  25. {ifcraftcorpus-1.1.0.data → ifcraftcorpus-1.2.1.data}/data/share/ifcraftcorpus/corpus/craft-foundations/creative_workflow_pipeline.md +0 -0
  26. {ifcraftcorpus-1.1.0.data → ifcraftcorpus-1.2.1.data}/data/share/ifcraftcorpus/corpus/craft-foundations/diegetic_design.md +0 -0
  27. {ifcraftcorpus-1.1.0.data → ifcraftcorpus-1.2.1.data}/data/share/ifcraftcorpus/corpus/craft-foundations/idea_capture_and_hooks.md +0 -0
  28. {ifcraftcorpus-1.1.0.data → ifcraftcorpus-1.2.1.data}/data/share/ifcraftcorpus/corpus/craft-foundations/if_platform_tools.md +0 -0
  29. {ifcraftcorpus-1.1.0.data → ifcraftcorpus-1.2.1.data}/data/share/ifcraftcorpus/corpus/craft-foundations/player_analytics_metrics.md +0 -0
  30. {ifcraftcorpus-1.1.0.data → ifcraftcorpus-1.2.1.data}/data/share/ifcraftcorpus/corpus/craft-foundations/quality_standards_if.md +0 -0
  31. {ifcraftcorpus-1.1.0.data → ifcraftcorpus-1.2.1.data}/data/share/ifcraftcorpus/corpus/craft-foundations/research_and_verification.md +0 -0
  32. {ifcraftcorpus-1.1.0.data → ifcraftcorpus-1.2.1.data}/data/share/ifcraftcorpus/corpus/craft-foundations/testing_interactive_fiction.md +0 -0
  33. {ifcraftcorpus-1.1.0.data → ifcraftcorpus-1.2.1.data}/data/share/ifcraftcorpus/corpus/emotional-design/conflict_patterns.md +0 -0
  34. {ifcraftcorpus-1.1.0.data → ifcraftcorpus-1.2.1.data}/data/share/ifcraftcorpus/corpus/emotional-design/emotional_beats.md +0 -0
  35. {ifcraftcorpus-1.1.0.data → ifcraftcorpus-1.2.1.data}/data/share/ifcraftcorpus/corpus/game-design/mechanics_design_patterns.md +0 -0
  36. {ifcraftcorpus-1.1.0.data → ifcraftcorpus-1.2.1.data}/data/share/ifcraftcorpus/corpus/genre-conventions/children_and_ya_conventions.md +0 -0
  37. {ifcraftcorpus-1.1.0.data → ifcraftcorpus-1.2.1.data}/data/share/ifcraftcorpus/corpus/genre-conventions/fantasy_conventions.md +0 -0
  38. {ifcraftcorpus-1.1.0.data → ifcraftcorpus-1.2.1.data}/data/share/ifcraftcorpus/corpus/genre-conventions/historical_fiction.md +0 -0
  39. {ifcraftcorpus-1.1.0.data → ifcraftcorpus-1.2.1.data}/data/share/ifcraftcorpus/corpus/genre-conventions/horror_conventions.md +0 -0
  40. {ifcraftcorpus-1.1.0.data → ifcraftcorpus-1.2.1.data}/data/share/ifcraftcorpus/corpus/genre-conventions/mystery_conventions.md +0 -0
  41. {ifcraftcorpus-1.1.0.data → ifcraftcorpus-1.2.1.data}/data/share/ifcraftcorpus/corpus/genre-conventions/sci_fi_conventions.md +0 -0
  42. {ifcraftcorpus-1.1.0.data → ifcraftcorpus-1.2.1.data}/data/share/ifcraftcorpus/corpus/narrative-structure/branching_narrative_construction.md +0 -0
  43. {ifcraftcorpus-1.1.0.data → ifcraftcorpus-1.2.1.data}/data/share/ifcraftcorpus/corpus/narrative-structure/branching_narrative_craft.md +0 -0
  44. {ifcraftcorpus-1.1.0.data → ifcraftcorpus-1.2.1.data}/data/share/ifcraftcorpus/corpus/narrative-structure/endings_patterns.md +0 -0
  45. {ifcraftcorpus-1.1.0.data → ifcraftcorpus-1.2.1.data}/data/share/ifcraftcorpus/corpus/narrative-structure/episodic_serialized_if.md +0 -0
  46. {ifcraftcorpus-1.1.0.data → ifcraftcorpus-1.2.1.data}/data/share/ifcraftcorpus/corpus/narrative-structure/nonlinear_structure.md +0 -0
  47. {ifcraftcorpus-1.1.0.data → ifcraftcorpus-1.2.1.data}/data/share/ifcraftcorpus/corpus/narrative-structure/pacing_and_tension.md +0 -0
  48. {ifcraftcorpus-1.1.0.data → ifcraftcorpus-1.2.1.data}/data/share/ifcraftcorpus/corpus/narrative-structure/romance_and_relationships.md +0 -0
  49. {ifcraftcorpus-1.1.0.data → ifcraftcorpus-1.2.1.data}/data/share/ifcraftcorpus/corpus/narrative-structure/scene_structure_and_beats.md +0 -0
  50. {ifcraftcorpus-1.1.0.data → ifcraftcorpus-1.2.1.data}/data/share/ifcraftcorpus/corpus/narrative-structure/scene_transitions.md +0 -0
  51. {ifcraftcorpus-1.1.0.data → ifcraftcorpus-1.2.1.data}/data/share/ifcraftcorpus/corpus/prose-and-language/character_voice.md +0 -0
  52. {ifcraftcorpus-1.1.0.data → ifcraftcorpus-1.2.1.data}/data/share/ifcraftcorpus/corpus/prose-and-language/dialogue_craft.md +0 -0
  53. {ifcraftcorpus-1.1.0.data → ifcraftcorpus-1.2.1.data}/data/share/ifcraftcorpus/corpus/prose-and-language/exposition_techniques.md +0 -0
  54. {ifcraftcorpus-1.1.0.data → ifcraftcorpus-1.2.1.data}/data/share/ifcraftcorpus/corpus/prose-and-language/narrative_point_of_view.md +0 -0
  55. {ifcraftcorpus-1.1.0.data → ifcraftcorpus-1.2.1.data}/data/share/ifcraftcorpus/corpus/prose-and-language/prose_patterns.md +0 -0
  56. {ifcraftcorpus-1.1.0.data → ifcraftcorpus-1.2.1.data}/data/share/ifcraftcorpus/corpus/prose-and-language/subtext_and_implication.md +0 -0
  57. {ifcraftcorpus-1.1.0.data → ifcraftcorpus-1.2.1.data}/data/share/ifcraftcorpus/corpus/prose-and-language/voice_register_consistency.md +0 -0
  58. {ifcraftcorpus-1.1.0.data → ifcraftcorpus-1.2.1.data}/data/share/ifcraftcorpus/corpus/scope-and-planning/scope_and_length.md +0 -0
  59. {ifcraftcorpus-1.1.0.data → ifcraftcorpus-1.2.1.data}/data/share/ifcraftcorpus/corpus/world-and-setting/canon_management.md +0 -0
  60. {ifcraftcorpus-1.1.0.data → ifcraftcorpus-1.2.1.data}/data/share/ifcraftcorpus/corpus/world-and-setting/setting_as_character.md +0 -0
  61. {ifcraftcorpus-1.1.0.data → ifcraftcorpus-1.2.1.data}/data/share/ifcraftcorpus/corpus/world-and-setting/worldbuilding_patterns.md +0 -0
  62. {ifcraftcorpus-1.1.0.dist-info → ifcraftcorpus-1.2.1.dist-info}/WHEEL +0 -0
  63. {ifcraftcorpus-1.1.0.dist-info → ifcraftcorpus-1.2.1.dist-info}/entry_points.txt +0 -0
  64. {ifcraftcorpus-1.1.0.dist-info → ifcraftcorpus-1.2.1.dist-info}/licenses/LICENSE +0 -0
  65. {ifcraftcorpus-1.1.0.dist-info → ifcraftcorpus-1.2.1.dist-info}/licenses/LICENSE-CONTENT +0 -0
@@ -10,6 +10,7 @@ Features:
10
10
  - Retrieve specific documents
11
11
  - List available documents and clusters
12
12
  - Get corpus statistics
13
+ - Subagent prompts for IF authoring workflows
13
14
 
14
15
  Installation:
15
16
  The MCP server requires the ``mcp`` extra::
@@ -51,18 +52,94 @@ Tools:
51
52
  corpus_stats: Get corpus statistics.
52
53
  embeddings_status: Check embedding provider and index status.
53
54
  build_embeddings: Build or rebuild semantic search embeddings.
55
+
56
+ Prompts:
57
+ if_story_architect: System prompt for an IF Story Architect agent.
58
+ if_prose_writer: System prompt for an IF Prose Writer agent.
59
+ if_quality_reviewer: System prompt for an IF Quality Reviewer agent.
60
+ if_genre_consultant: System prompt for an IF Genre Consultant agent.
61
+ if_world_curator: System prompt for an IF World Curator agent.
62
+ if_platform_advisor: System prompt for an IF Platform Advisor agent.
54
63
  """
55
64
 
56
65
  from __future__ import annotations
57
66
 
67
+ import logging
58
68
  import os
69
+ import sys
70
+ from collections.abc import Callable
59
71
  from pathlib import Path
60
- from typing import Literal
72
+ from typing import TYPE_CHECKING, Any, Literal, TypeVar
61
73
 
62
74
  from fastmcp import FastMCP
75
+ from fastmcp.prompts import Message
76
+ from mcp.types import PromptMessage
63
77
 
78
+ from ifcraftcorpus.logging_utils import configure_logging
64
79
  from ifcraftcorpus.search import Corpus
65
80
 
81
+ _CONFIGURED_LOG_LEVEL = configure_logging()
82
+ logger = logging.getLogger(__name__)
83
+ if _CONFIGURED_LOG_LEVEL is not None:
84
+ logger.info("MCP logging enabled at %s", logging.getLevelName(_CONFIGURED_LOG_LEVEL))
85
+
86
+
87
+ def _truncate(value: str, limit: int = 200) -> str:
88
+ """Truncate long strings for safe structured logging."""
89
+
90
+ if len(value) <= limit:
91
+ return value
92
+ return f"{value[:limit]}..."
93
+
94
+
95
+ def _get_subagents_dir() -> Path:
96
+ """Get the path to the subagents directory.
97
+
98
+ Returns the subagents directory from either:
99
+ 1. The installed package location (share/ifcraftcorpus/subagents)
100
+ 2. The development location (project root/subagents)
101
+ """
102
+ # Try installed location first
103
+ if sys.prefix != sys.base_prefix:
104
+ # We're in a virtual environment
105
+ installed_path = Path(sys.prefix) / "share" / "ifcraftcorpus" / "subagents"
106
+ if installed_path.exists():
107
+ logger.debug("Using installed subagents directory: %s", installed_path)
108
+ return installed_path
109
+
110
+ # Try development location (relative to this file)
111
+ dev_path = Path(__file__).parent.parent.parent.parent / "subagents"
112
+ if dev_path.exists():
113
+ logger.debug("Using development subagents directory: %s", dev_path)
114
+ return dev_path
115
+
116
+ # Fallback to current directory
117
+ fallback = Path("subagents")
118
+ logger.debug("Using fallback subagents directory: %s", fallback)
119
+ return fallback
120
+
121
+
122
+ def _load_subagent_template(name: str) -> str:
123
+ """Load a subagent template by name.
124
+
125
+ Args:
126
+ name: Template name (without .md extension)
127
+
128
+ Returns:
129
+ The template content as a string.
130
+
131
+ Raises:
132
+ FileNotFoundError: If the template doesn't exist.
133
+ """
134
+ subagents_dir = _get_subagents_dir()
135
+ template_path = subagents_dir / f"{name}.md"
136
+ if not template_path.exists():
137
+ logger.error("Subagent template missing: %s", template_path)
138
+ raise FileNotFoundError(f"Subagent template not found: {template_path}")
139
+ logger.debug("Loaded subagent template %s", template_path.name)
140
+ return template_path.read_text(encoding="utf-8")
141
+
142
+
66
143
  # Initialize FastMCP server
67
144
  mcp = FastMCP(
68
145
  name="IF Craft Corpus",
@@ -74,6 +151,16 @@ mcp = FastMCP(
74
151
  """,
75
152
  )
76
153
 
154
+ if TYPE_CHECKING:
155
+ TCallable = TypeVar("TCallable", bound=Callable[..., Any])
156
+
157
+ def tool(func: TCallable, /) -> TCallable: ...
158
+
159
+ def prompt(*args: Any, **kwargs: Any) -> Callable[[TCallable], TCallable]: ...
160
+ else: # pragma: no cover - runtime aliases for decorators
161
+ tool = mcp.tool
162
+ prompt = mcp.prompt
163
+
77
164
  # Global corpus instance (initialized on first use)
78
165
  _corpus: Corpus | None = None
79
166
 
@@ -89,16 +176,17 @@ def get_corpus() -> Corpus:
89
176
  """
90
177
  global _corpus
91
178
  if _corpus is None:
179
+ logger.info("Initializing shared Corpus instance for MCP server")
92
180
  _corpus = Corpus()
93
181
  return _corpus
94
182
 
95
183
 
96
- @mcp.tool
184
+ @tool
97
185
  def search_corpus(
98
186
  query: str,
99
187
  cluster: str | None = None,
100
188
  limit: int = 5,
101
- ) -> list[dict]:
189
+ ) -> list[dict[str, Any]]:
102
190
  """Search the IF Craft Corpus for writing guidance.
103
191
 
104
192
  Use this tool to find craft advice for interactive fiction writing,
@@ -120,8 +208,21 @@ def search_corpus(
120
208
  """
121
209
  limit = max(1, min(20, limit))
122
210
 
211
+ logger.debug(
212
+ "search_corpus(query=%r, cluster=%s, limit=%s)",
213
+ _truncate(query),
214
+ cluster,
215
+ limit,
216
+ )
217
+
123
218
  corpus = get_corpus()
124
- results = corpus.search(query, cluster=cluster, limit=limit)
219
+ try:
220
+ results = corpus.search(query, cluster=cluster, limit=limit)
221
+ except Exception: # pragma: no cover - defensive logging
222
+ logger.exception("search_corpus failed")
223
+ raise
224
+
225
+ logger.debug("search_corpus returning %s results", len(results))
125
226
 
126
227
  return [
127
228
  {
@@ -135,8 +236,8 @@ def search_corpus(
135
236
  ]
136
237
 
137
238
 
138
- @mcp.tool
139
- def get_document(name: str) -> dict | None:
239
+ @tool
240
+ def get_document(name: str) -> dict[str, Any] | None:
140
241
  """Get a specific document from the IF Craft Corpus.
141
242
 
142
243
  Use this tool when you need the full content of a known document,
@@ -149,12 +250,18 @@ def get_document(name: str) -> dict | None:
149
250
  Returns:
150
251
  Full document with title, summary, cluster, topics, and all sections.
151
252
  """
253
+ logger.debug("get_document(%s)", name)
152
254
  corpus = get_corpus()
153
- return corpus.get_document(name)
255
+ document = corpus.get_document(name)
256
+ if document is None:
257
+ logger.info("Document not found: %s", name)
258
+ else:
259
+ logger.debug("Document %s retrieved", name)
260
+ return document
154
261
 
155
262
 
156
- @mcp.tool
157
- def list_documents(cluster: str | None = None) -> list[dict]:
263
+ @tool
264
+ def list_documents(cluster: str | None = None) -> list[dict[str, Any]]:
158
265
  """List all documents in the IF Craft Corpus.
159
266
 
160
267
  Use this tool to discover what craft guidance is available.
@@ -165,17 +272,19 @@ def list_documents(cluster: str | None = None) -> list[dict]:
165
272
  Returns:
166
273
  List of documents with name, title, cluster, and topics.
167
274
  """
275
+ logger.debug("list_documents(cluster=%s)", cluster)
168
276
  corpus = get_corpus()
169
277
  docs = corpus.list_documents()
170
278
 
171
279
  if cluster:
172
280
  docs = [d for d in docs if d["cluster"] == cluster]
173
281
 
282
+ logger.debug("list_documents returning %s entries", len(docs))
174
283
  return docs
175
284
 
176
285
 
177
- @mcp.tool
178
- def list_clusters() -> list[dict]:
286
+ @tool
287
+ def list_clusters() -> list[dict[str, Any]]:
179
288
  """List all topic clusters in the IF Craft Corpus.
180
289
 
181
290
  Each cluster groups related craft documents. Use this to understand
@@ -184,37 +293,48 @@ def list_clusters() -> list[dict]:
184
293
  Returns:
185
294
  List of clusters with names and document counts.
186
295
  """
296
+ logger.debug("list_clusters invoked")
187
297
  corpus = get_corpus()
188
298
  clusters = corpus.list_clusters()
189
299
  docs = corpus.list_documents()
190
300
 
191
301
  # Count documents per cluster
192
- counts = {}
302
+ counts: dict[str, int] = {}
193
303
  for d in docs:
194
304
  c = d["cluster"]
195
305
  counts[c] = counts.get(c, 0) + 1
196
306
 
197
- return [{"name": c, "document_count": counts.get(c, 0)} for c in clusters]
307
+ cluster_info = [{"name": c, "document_count": counts.get(c, 0)} for c in clusters]
308
+ logger.debug("list_clusters returning %s clusters", len(cluster_info))
309
+ return cluster_info
198
310
 
199
311
 
200
- @mcp.tool
201
- def corpus_stats() -> dict:
312
+ @tool
313
+ def corpus_stats() -> dict[str, Any]:
202
314
  """Get statistics about the IF Craft Corpus.
203
315
 
204
316
  Returns:
205
317
  Statistics including document count, cluster count, and availability.
206
318
  """
319
+ logger.debug("corpus_stats invoked")
207
320
  corpus = get_corpus()
208
- return {
321
+ stats = {
209
322
  "document_count": corpus.document_count(),
210
323
  "cluster_count": len(corpus.list_clusters()),
211
324
  "clusters": corpus.list_clusters(),
212
325
  "semantic_search_available": corpus.has_semantic_search,
213
326
  }
327
+ logger.debug(
328
+ "corpus_stats: docs=%s clusters=%s semantic=%s",
329
+ stats["document_count"],
330
+ stats["cluster_count"],
331
+ stats["semantic_search_available"],
332
+ )
333
+ return stats
214
334
 
215
335
 
216
- @mcp.tool
217
- def embeddings_status() -> dict:
336
+ @tool
337
+ def embeddings_status() -> dict[str, Any]:
218
338
  """Get status of embedding providers and index.
219
339
 
220
340
  Returns information about available embedding providers (Ollama, OpenAI,
@@ -223,7 +343,8 @@ def embeddings_status() -> dict:
223
343
  Returns:
224
344
  Dict with provider availability and embedding index status.
225
345
  """
226
- result = {
346
+ logger.debug("embeddings_status invoked")
347
+ result: dict[str, Any] = {
227
348
  "semantic_search_available": get_corpus().has_semantic_search,
228
349
  "providers": {},
229
350
  "saved_embeddings": None,
@@ -251,13 +372,15 @@ def embeddings_status() -> dict:
251
372
  "model": provider.model if available else None,
252
373
  "dimension": provider.dimension if available else None,
253
374
  }
254
- except Exception:
375
+ except Exception as exc: # pragma: no cover - defensive logging
376
+ logger.warning("Failed to inspect embedding provider %s: %s", name, exc)
255
377
  result["providers"][name] = {"available": False, "error": "import_failed"}
256
378
 
257
379
  # Auto-detect best provider
258
380
  auto = get_embedding_provider()
259
381
  result["auto_detected_provider"] = auto.provider_name if auto else None
260
382
  except ImportError:
383
+ logger.warning("Embedding providers module not importable for status call")
261
384
  result["providers_error"] = "providers module not available"
262
385
 
263
386
  # Check for saved embeddings
@@ -274,14 +397,20 @@ def embeddings_status() -> dict:
274
397
  "count": len(meta.get("metadata", [])),
275
398
  }
276
399
 
400
+ logger.debug(
401
+ "embeddings_status semantic=%s providers=%s saved=%s",
402
+ result["semantic_search_available"],
403
+ list(result["providers"].keys()),
404
+ bool(result["saved_embeddings"]),
405
+ )
277
406
  return result
278
407
 
279
408
 
280
- @mcp.tool
409
+ @tool
281
410
  def build_embeddings(
282
411
  provider: str | None = None,
283
412
  force: bool = False,
284
- ) -> dict:
413
+ ) -> dict[str, Any]:
285
414
  """Build or rebuild the embedding index for semantic search.
286
415
 
287
416
  Builds embeddings for all corpus documents using the specified provider.
@@ -303,6 +432,8 @@ def build_embeddings(
303
432
  """
304
433
  global _corpus
305
434
 
435
+ logger.info("build_embeddings requested provider=%s force=%s", provider, force)
436
+
306
437
  try:
307
438
  from ifcraftcorpus.providers import (
308
439
  OllamaEmbeddings,
@@ -311,6 +442,7 @@ def build_embeddings(
311
442
  get_embedding_provider,
312
443
  )
313
444
  except ImportError:
445
+ logger.warning("Embedding provider modules not installed")
314
446
  return {
315
447
  "error": "Embedding providers not available. "
316
448
  "Install with [embeddings-api] or [embeddings] extras."
@@ -325,6 +457,7 @@ def build_embeddings(
325
457
  "sentence_transformers": SentenceTransformersEmbeddings,
326
458
  }
327
459
  if provider not in provider_map:
460
+ logger.warning("Unknown embeddings provider requested: %s", provider)
328
461
  return {
329
462
  "error": f"Unknown provider: {provider}. Use: ollama, openai, sentence_transformers"
330
463
  }
@@ -333,12 +466,14 @@ def build_embeddings(
333
466
  embedding_provider = get_embedding_provider()
334
467
 
335
468
  if not embedding_provider:
469
+ logger.warning("No embedding provider available for build request")
336
470
  return {
337
471
  "error": "No embedding provider available. "
338
472
  "Configure Ollama, set OPENAI_API_KEY, or install sentence-transformers."
339
473
  }
340
474
 
341
475
  if not embedding_provider.check_availability():
476
+ logger.warning("Embedding provider %s unavailable", embedding_provider.provider_name)
342
477
  return {"error": f"Provider {embedding_provider.provider_name} is not available."}
343
478
 
344
479
  # Configure paths
@@ -346,6 +481,7 @@ def build_embeddings(
346
481
 
347
482
  # Check if already exists
348
483
  if not force and embeddings_path.exists() and (embeddings_path / "metadata.json").exists():
484
+ logger.info("Embedding build skipped; existing index at %s", embeddings_path)
349
485
  return {
350
486
  "status": "skipped",
351
487
  "message": "Embeddings already exist. Use force=True to rebuild.",
@@ -360,6 +496,12 @@ def build_embeddings(
360
496
 
361
497
  # Build embeddings
362
498
  count = corpus.build_embeddings(force=force)
499
+ logger.info(
500
+ "Embedding build complete items=%s provider=%s model=%s",
501
+ count,
502
+ embedding_provider.provider_name,
503
+ embedding_provider.model,
504
+ )
363
505
 
364
506
  # Update global corpus to use new embeddings
365
507
  _corpus = Corpus(embeddings_path=embeddings_path)
@@ -373,6 +515,258 @@ def build_embeddings(
373
515
  }
374
516
 
375
517
 
518
+ # =============================================================================
519
+ # Subagent Prompts
520
+ # =============================================================================
521
+ #
522
+ # These prompts provide system prompts for specialized IF authoring agents.
523
+ # Each agent has a specific role in the IF creation workflow.
524
+
525
+
526
+ @prompt(
527
+ name="if_story_architect",
528
+ description="System prompt for an IF Story Architect - an orchestrator agent that "
529
+ "plans narrative structure, decomposes IF projects, and coordinates creation.",
530
+ )
531
+ def if_story_architect_prompt(
532
+ project_name: str | None = None,
533
+ genre: str | None = None,
534
+ ) -> list[PromptMessage]:
535
+ """Get the IF Story Architect system prompt.
536
+
537
+ Args:
538
+ project_name: Optional project name to include in the prompt context.
539
+ genre: Optional genre to emphasize (fantasy, horror, mystery, etc.).
540
+
541
+ Returns:
542
+ System prompt messages for the Story Architect agent.
543
+ """
544
+ template = _load_subagent_template("if_story_architect")
545
+
546
+ # Add optional context
547
+ context_parts = []
548
+ if project_name:
549
+ context_parts.append(f"Project: {project_name}")
550
+ if genre:
551
+ context_parts.append(f"Genre: {genre}")
552
+
553
+ if context_parts:
554
+ context = "\n\n---\n\n## Current Project Context\n\n" + "\n".join(context_parts)
555
+ template = template + context
556
+
557
+ return [Message(template, role="user")]
558
+
559
+
560
+ @prompt(
561
+ name="if_prose_writer",
562
+ description="System prompt for an IF Prose Writer - a specialist agent that "
563
+ "creates narrative content including prose, dialogue, and scene text.",
564
+ )
565
+ def if_prose_writer_prompt(
566
+ genre: str | None = None,
567
+ pov: str | None = None,
568
+ ) -> list[PromptMessage]:
569
+ """Get the IF Prose Writer system prompt.
570
+
571
+ Args:
572
+ genre: Optional genre to emphasize (fantasy, horror, mystery, etc.).
573
+ pov: Optional point of view (first, second, third).
574
+
575
+ Returns:
576
+ System prompt messages for the Prose Writer agent.
577
+ """
578
+ template = _load_subagent_template("if_prose_writer")
579
+
580
+ # Add optional context
581
+ context_parts = []
582
+ if genre:
583
+ context_parts.append(f"Genre: {genre}")
584
+ if pov:
585
+ context_parts.append(f"Point of View: {pov}")
586
+
587
+ if context_parts:
588
+ context = "\n\n---\n\n## Current Project Context\n\n" + "\n".join(context_parts)
589
+ template = template + context
590
+
591
+ return [Message(template, role="user")]
592
+
593
+
594
+ @prompt(
595
+ name="if_quality_reviewer",
596
+ description="System prompt for an IF Quality Reviewer - a validator agent that "
597
+ "reviews IF content for craft quality, consistency, and standards compliance.",
598
+ )
599
+ def if_quality_reviewer_prompt(
600
+ focus_areas: str | None = None,
601
+ ) -> list[PromptMessage]:
602
+ """Get the IF Quality Reviewer system prompt.
603
+
604
+ Args:
605
+ focus_areas: Optional comma-separated list of areas to focus on
606
+ (e.g., "voice,pacing,continuity").
607
+
608
+ Returns:
609
+ System prompt messages for the Quality Reviewer agent.
610
+ """
611
+ template = _load_subagent_template("if_quality_reviewer")
612
+
613
+ if focus_areas:
614
+ context = f"\n\n---\n\n## Review Focus\n\nPrioritize these areas: {focus_areas}"
615
+ template = template + context
616
+
617
+ return [Message(template, role="user")]
618
+
619
+
620
+ @prompt(
621
+ name="if_genre_consultant",
622
+ description="System prompt for an IF Genre Consultant - a researcher agent that "
623
+ "provides genre-specific guidance on conventions, tropes, and reader expectations.",
624
+ )
625
+ def if_genre_consultant_prompt(
626
+ primary_genre: str | None = None,
627
+ secondary_genre: str | None = None,
628
+ ) -> list[PromptMessage]:
629
+ """Get the IF Genre Consultant system prompt.
630
+
631
+ Args:
632
+ primary_genre: Optional primary genre to focus on.
633
+ secondary_genre: Optional secondary genre for blending advice.
634
+
635
+ Returns:
636
+ System prompt messages for the Genre Consultant agent.
637
+ """
638
+ template = _load_subagent_template("if_genre_consultant")
639
+
640
+ context_parts = []
641
+ if primary_genre:
642
+ context_parts.append(f"Primary Genre: {primary_genre}")
643
+ if secondary_genre:
644
+ context_parts.append(f"Secondary Genre: {secondary_genre}")
645
+
646
+ if context_parts:
647
+ context = "\n\n---\n\n## Genre Focus\n\n" + "\n".join(context_parts)
648
+ template = template + context
649
+
650
+ return [Message(template, role="user")]
651
+
652
+
653
+ @prompt(
654
+ name="if_world_curator",
655
+ description="System prompt for an IF World Curator - a curator agent that "
656
+ "maintains world consistency, manages canon, and ensures setting coherence.",
657
+ )
658
+ def if_world_curator_prompt(
659
+ world_name: str | None = None,
660
+ setting_type: str | None = None,
661
+ ) -> list[PromptMessage]:
662
+ """Get the IF World Curator system prompt.
663
+
664
+ Args:
665
+ world_name: Optional name of the world/setting.
666
+ setting_type: Optional setting type (e.g., "fantasy medieval", "sci-fi space").
667
+
668
+ Returns:
669
+ System prompt messages for the World Curator agent.
670
+ """
671
+ template = _load_subagent_template("if_world_curator")
672
+
673
+ context_parts = []
674
+ if world_name:
675
+ context_parts.append(f"World: {world_name}")
676
+ if setting_type:
677
+ context_parts.append(f"Setting Type: {setting_type}")
678
+
679
+ if context_parts:
680
+ context = "\n\n---\n\n## World Context\n\n" + "\n".join(context_parts)
681
+ template = template + context
682
+
683
+ return [Message(template, role="user")]
684
+
685
+
686
+ @prompt(
687
+ name="if_platform_advisor",
688
+ description="System prompt for an IF Platform Advisor - a researcher agent that "
689
+ "provides guidance on tools, platforms, and technical implementation.",
690
+ )
691
+ def if_platform_advisor_prompt(
692
+ target_platform: str | None = None,
693
+ team_size: str | None = None,
694
+ ) -> list[PromptMessage]:
695
+ """Get the IF Platform Advisor system prompt.
696
+
697
+ Args:
698
+ target_platform: Optional target platform if already decided.
699
+ team_size: Optional team size (solo, small, large).
700
+
701
+ Returns:
702
+ System prompt messages for the Platform Advisor agent.
703
+ """
704
+ template = _load_subagent_template("if_platform_advisor")
705
+
706
+ context_parts = []
707
+ if target_platform:
708
+ context_parts.append(f"Target Platform: {target_platform}")
709
+ if team_size:
710
+ context_parts.append(f"Team Size: {team_size}")
711
+
712
+ if context_parts:
713
+ context = "\n\n---\n\n## Project Context\n\n" + "\n".join(context_parts)
714
+ template = template + context
715
+
716
+ return [Message(template, role="user")]
717
+
718
+
719
+ @tool
720
+ def list_subagents() -> list[dict[str, Any]]:
721
+ """List all available IF subagent prompts.
722
+
723
+ Returns a list of subagent templates that can be used as system prompts
724
+ for specialized IF authoring agents.
725
+
726
+ Returns:
727
+ List of subagents with name, description, and parameters.
728
+ """
729
+ logger.debug("list_subagents invoked")
730
+ return [
731
+ {
732
+ "name": "if_story_architect",
733
+ "description": "Orchestrator that plans narrative structure and coordinates creation",
734
+ "archetype": "orchestrator",
735
+ "parameters": ["project_name", "genre"],
736
+ },
737
+ {
738
+ "name": "if_prose_writer",
739
+ "description": "Specialist that creates narrative prose, dialogue, and scene text",
740
+ "archetype": "creator",
741
+ "parameters": ["genre", "pov"],
742
+ },
743
+ {
744
+ "name": "if_quality_reviewer",
745
+ "description": "Validator agent that reviews content for quality and consistency",
746
+ "archetype": "validator",
747
+ "parameters": ["focus_areas"],
748
+ },
749
+ {
750
+ "name": "if_genre_consultant",
751
+ "description": "Researcher agent for genre conventions, tropes, and expectations",
752
+ "archetype": "researcher",
753
+ "parameters": ["primary_genre", "secondary_genre"],
754
+ },
755
+ {
756
+ "name": "if_world_curator",
757
+ "description": "Curator agent that maintains world consistency and canon",
758
+ "archetype": "curator",
759
+ "parameters": ["world_name", "setting_type"],
760
+ },
761
+ {
762
+ "name": "if_platform_advisor",
763
+ "description": "Researcher agent for tools, platforms, and technical implementation",
764
+ "archetype": "researcher",
765
+ "parameters": ["target_platform", "team_size"],
766
+ },
767
+ ]
768
+
769
+
376
770
  def run_server(
377
771
  transport: Literal["stdio", "http"] = "stdio",
378
772
  host: str = "127.0.0.1",
@@ -400,8 +794,10 @@ def run_server(
400
794
  >>> run_server(transport="http", host="0.0.0.0", port=8080)
401
795
  """
402
796
  if transport == "http":
797
+ logger.info("Starting MCP server (http) host=%s port=%s", host, port)
403
798
  mcp.run(transport="http", host=host, port=port)
404
799
  else:
800
+ logger.info("Starting MCP server (stdio)")
405
801
  mcp.run()
406
802
 
407
803
 
@@ -21,7 +21,7 @@ from dataclasses import dataclass
21
21
  from typing import TYPE_CHECKING
22
22
 
23
23
  if TYPE_CHECKING:
24
- pass
24
+ from sentence_transformers import SentenceTransformer
25
25
 
26
26
  logger = logging.getLogger(__name__)
27
27
 
@@ -283,7 +283,7 @@ class OpenAIEmbeddings(EmbeddingProvider):
283
283
  "https://api.openai.com/v1/models",
284
284
  headers={"Authorization": f"Bearer {self._api_key}"},
285
285
  )
286
- return response.status_code == 200
286
+ return bool(response.status_code == 200)
287
287
 
288
288
  except httpx.RequestError as e:
289
289
  logger.debug(f"OpenAI availability check failed: {e}")
@@ -314,12 +314,12 @@ class SentenceTransformersEmbeddings(EmbeddingProvider):
314
314
  """
315
315
  self._model_name = model or DEFAULT_MODELS["sentence-transformers"]
316
316
  self._dimension = MODEL_DIMENSIONS.get(self._model_name, 384)
317
- self._model_instance = None
317
+ self._model_instance: SentenceTransformer | None = None
318
318
 
319
319
  if not lazy_load:
320
320
  self._load_model()
321
321
 
322
- def _load_model(self):
322
+ def _load_model(self) -> SentenceTransformer:
323
323
  """Load the sentence transformer model."""
324
324
  if self._model_instance is None:
325
325
  try: