flonat-research 0.1.0

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 (285) hide show
  1. package/.claude/agents/domain-reviewer.md +336 -0
  2. package/.claude/agents/fixer.md +226 -0
  3. package/.claude/agents/paper-critic.md +370 -0
  4. package/.claude/agents/peer-reviewer.md +289 -0
  5. package/.claude/agents/proposal-reviewer.md +215 -0
  6. package/.claude/agents/referee2-reviewer.md +367 -0
  7. package/.claude/agents/references/journal-referee-profiles.md +354 -0
  8. package/.claude/agents/references/paper-critic/council-personas.md +77 -0
  9. package/.claude/agents/references/paper-critic/council-prompts.md +198 -0
  10. package/.claude/agents/references/peer-reviewer/report-template.md +199 -0
  11. package/.claude/agents/references/peer-reviewer/sa-prompts.md +260 -0
  12. package/.claude/agents/references/peer-reviewer/security-scan.md +188 -0
  13. package/.claude/agents/references/proposal-reviewer/report-template.md +144 -0
  14. package/.claude/agents/references/proposal-reviewer/sa-prompts.md +149 -0
  15. package/.claude/agents/references/referee-config.md +114 -0
  16. package/.claude/agents/references/referee2-reviewer/audit-checklists.md +287 -0
  17. package/.claude/agents/references/referee2-reviewer/report-template.md +334 -0
  18. package/.claude/rules/design-before-results.md +52 -0
  19. package/.claude/rules/ignore-agents-md.md +17 -0
  20. package/.claude/rules/ignore-gemini-md.md +17 -0
  21. package/.claude/rules/lean-claude-md.md +45 -0
  22. package/.claude/rules/learn-tags.md +99 -0
  23. package/.claude/rules/overleaf-separation.md +67 -0
  24. package/.claude/rules/plan-first.md +175 -0
  25. package/.claude/rules/read-docs-first.md +50 -0
  26. package/.claude/rules/scope-discipline.md +28 -0
  27. package/.claude/settings.json +125 -0
  28. package/.context/current-focus.md +33 -0
  29. package/.context/preferences/priorities.md +36 -0
  30. package/.context/preferences/task-naming.md +28 -0
  31. package/.context/profile.md +29 -0
  32. package/.context/projects/_index.md +41 -0
  33. package/.context/projects/papers/nudge-exp.md +22 -0
  34. package/.context/projects/papers/uncertainty.md +31 -0
  35. package/.context/resources/claude-scientific-writer-review.md +48 -0
  36. package/.context/resources/cunningham-multi-analyst-agents.md +104 -0
  37. package/.context/resources/cunningham-multilang-code-audit.md +62 -0
  38. package/.context/resources/google-ai-co-scientist-review.md +72 -0
  39. package/.context/resources/karpathy-llm-council-review.md +58 -0
  40. package/.context/resources/multi-coder-reliability-protocol.md +175 -0
  41. package/.context/resources/pedro-santanna-takeaways.md +96 -0
  42. package/.context/resources/venue-rankings/abs_ajg_2024.csv +1823 -0
  43. package/.context/resources/venue-rankings/abs_ajg_2024_econ.csv +356 -0
  44. package/.context/resources/venue-rankings/cabs_4_4star_theory.csv +40 -0
  45. package/.context/resources/venue-rankings/core_2026.csv +801 -0
  46. package/.context/resources/venue-rankings.md +147 -0
  47. package/.context/workflows/README.md +69 -0
  48. package/.context/workflows/daily-review.md +91 -0
  49. package/.context/workflows/meeting-actions.md +108 -0
  50. package/.context/workflows/replication-protocol.md +155 -0
  51. package/.context/workflows/weekly-review.md +113 -0
  52. package/.mcp-server-biblio/formatters.py +158 -0
  53. package/.mcp-server-biblio/pyproject.toml +11 -0
  54. package/.mcp-server-biblio/server.py +678 -0
  55. package/.mcp-server-biblio/sources/__init__.py +14 -0
  56. package/.mcp-server-biblio/sources/base.py +73 -0
  57. package/.mcp-server-biblio/sources/formatters.py +83 -0
  58. package/.mcp-server-biblio/sources/models.py +22 -0
  59. package/.mcp-server-biblio/sources/multi_source.py +243 -0
  60. package/.mcp-server-biblio/sources/openalex_source.py +183 -0
  61. package/.mcp-server-biblio/sources/scopus_source.py +309 -0
  62. package/.mcp-server-biblio/sources/wos_source.py +508 -0
  63. package/.mcp-server-biblio/uv.lock +896 -0
  64. package/.scripts/README.md +161 -0
  65. package/.scripts/ai_pattern_density.py +446 -0
  66. package/.scripts/conf +445 -0
  67. package/.scripts/config.py +122 -0
  68. package/.scripts/count_inventory.py +275 -0
  69. package/.scripts/daily_digest.py +288 -0
  70. package/.scripts/done +177 -0
  71. package/.scripts/extract_meeting_actions.py +223 -0
  72. package/.scripts/focus +176 -0
  73. package/.scripts/generate-codex-agents-md.py +217 -0
  74. package/.scripts/inbox +194 -0
  75. package/.scripts/notion_helpers.py +325 -0
  76. package/.scripts/openalex/query_helpers.py +306 -0
  77. package/.scripts/papers +227 -0
  78. package/.scripts/query +223 -0
  79. package/.scripts/session-history.py +201 -0
  80. package/.scripts/skill-health.py +516 -0
  81. package/.scripts/skill-log-miner.py +273 -0
  82. package/.scripts/sync-to-codex.sh +252 -0
  83. package/.scripts/task +213 -0
  84. package/.scripts/tasks +190 -0
  85. package/.scripts/week +206 -0
  86. package/CLAUDE.md +197 -0
  87. package/LICENSE +21 -0
  88. package/MEMORY.md +38 -0
  89. package/README.md +269 -0
  90. package/docs/agents.md +44 -0
  91. package/docs/bibliography-setup.md +55 -0
  92. package/docs/council-mode.md +36 -0
  93. package/docs/getting-started.md +245 -0
  94. package/docs/hooks.md +38 -0
  95. package/docs/mcp-servers.md +82 -0
  96. package/docs/notion-setup.md +109 -0
  97. package/docs/rules.md +33 -0
  98. package/docs/scripts.md +303 -0
  99. package/docs/setup-overview/setup-overview.pdf +0 -0
  100. package/docs/skills.md +70 -0
  101. package/docs/system.md +159 -0
  102. package/hooks/block-destructive-git.sh +66 -0
  103. package/hooks/context-monitor.py +114 -0
  104. package/hooks/postcompact-restore.py +157 -0
  105. package/hooks/precompact-autosave.py +181 -0
  106. package/hooks/promise-checker.sh +124 -0
  107. package/hooks/protect-source-files.sh +81 -0
  108. package/hooks/resume-context-loader.sh +53 -0
  109. package/hooks/startup-context-loader.sh +102 -0
  110. package/package.json +51 -0
  111. package/packages/cli-council/.github/workflows/claude-code-review.yml +44 -0
  112. package/packages/cli-council/.github/workflows/claude.yml +50 -0
  113. package/packages/cli-council/README.md +100 -0
  114. package/packages/cli-council/pyproject.toml +43 -0
  115. package/packages/cli-council/src/cli_council/__init__.py +19 -0
  116. package/packages/cli-council/src/cli_council/__main__.py +185 -0
  117. package/packages/cli-council/src/cli_council/backends/__init__.py +8 -0
  118. package/packages/cli-council/src/cli_council/backends/base.py +81 -0
  119. package/packages/cli-council/src/cli_council/backends/claude.py +25 -0
  120. package/packages/cli-council/src/cli_council/backends/codex.py +27 -0
  121. package/packages/cli-council/src/cli_council/backends/gemini.py +26 -0
  122. package/packages/cli-council/src/cli_council/checkpoint.py +212 -0
  123. package/packages/cli-council/src/cli_council/config.py +51 -0
  124. package/packages/cli-council/src/cli_council/council.py +391 -0
  125. package/packages/cli-council/src/cli_council/models.py +46 -0
  126. package/packages/llm-council/.github/workflows/claude-code-review.yml +44 -0
  127. package/packages/llm-council/.github/workflows/claude.yml +50 -0
  128. package/packages/llm-council/README.md +453 -0
  129. package/packages/llm-council/pyproject.toml +42 -0
  130. package/packages/llm-council/src/llm_council/__init__.py +23 -0
  131. package/packages/llm-council/src/llm_council/__main__.py +259 -0
  132. package/packages/llm-council/src/llm_council/checkpoint.py +193 -0
  133. package/packages/llm-council/src/llm_council/client.py +253 -0
  134. package/packages/llm-council/src/llm_council/config.py +232 -0
  135. package/packages/llm-council/src/llm_council/council.py +482 -0
  136. package/packages/llm-council/src/llm_council/models.py +46 -0
  137. package/packages/mcp-bibliography/MEMORY.md +31 -0
  138. package/packages/mcp-bibliography/_app.py +226 -0
  139. package/packages/mcp-bibliography/formatters.py +158 -0
  140. package/packages/mcp-bibliography/log/2026-03-13-2100.md +35 -0
  141. package/packages/mcp-bibliography/pyproject.toml +15 -0
  142. package/packages/mcp-bibliography/run.sh +20 -0
  143. package/packages/mcp-bibliography/scholarly_formatters.py +83 -0
  144. package/packages/mcp-bibliography/server.py +1857 -0
  145. package/packages/mcp-bibliography/tools/__init__.py +28 -0
  146. package/packages/mcp-bibliography/tools/_registry.py +19 -0
  147. package/packages/mcp-bibliography/tools/altmetric.py +107 -0
  148. package/packages/mcp-bibliography/tools/core.py +92 -0
  149. package/packages/mcp-bibliography/tools/dblp.py +52 -0
  150. package/packages/mcp-bibliography/tools/openalex.py +296 -0
  151. package/packages/mcp-bibliography/tools/opencitations.py +102 -0
  152. package/packages/mcp-bibliography/tools/openreview.py +179 -0
  153. package/packages/mcp-bibliography/tools/orcid.py +131 -0
  154. package/packages/mcp-bibliography/tools/scholarly.py +575 -0
  155. package/packages/mcp-bibliography/tools/unpaywall.py +63 -0
  156. package/packages/mcp-bibliography/tools/zenodo.py +123 -0
  157. package/packages/mcp-bibliography/uv.lock +711 -0
  158. package/scripts/setup.sh +143 -0
  159. package/skills/beamer-deck/SKILL.md +199 -0
  160. package/skills/beamer-deck/references/quality-rubric.md +54 -0
  161. package/skills/beamer-deck/references/review-prompts.md +106 -0
  162. package/skills/bib-validate/SKILL.md +261 -0
  163. package/skills/bib-validate/references/council-mode.md +34 -0
  164. package/skills/bib-validate/references/deep-verify.md +79 -0
  165. package/skills/bib-validate/references/fix-mode.md +36 -0
  166. package/skills/bib-validate/references/openalex-verification.md +45 -0
  167. package/skills/bib-validate/references/preprint-check.md +31 -0
  168. package/skills/bib-validate/references/ref-manager-crossref.md +41 -0
  169. package/skills/bib-validate/references/report-template.md +82 -0
  170. package/skills/code-archaeology/SKILL.md +141 -0
  171. package/skills/code-review/SKILL.md +265 -0
  172. package/skills/code-review/references/quality-rubric.md +67 -0
  173. package/skills/consolidate-memory/SKILL.md +208 -0
  174. package/skills/context-status/SKILL.md +126 -0
  175. package/skills/creation-guard/SKILL.md +230 -0
  176. package/skills/devils-advocate/SKILL.md +130 -0
  177. package/skills/devils-advocate/references/competing-hypotheses.md +83 -0
  178. package/skills/init-project/SKILL.md +115 -0
  179. package/skills/init-project-course/references/memory-and-settings.md +92 -0
  180. package/skills/init-project-course/references/organise-templates.md +94 -0
  181. package/skills/init-project-course/skill.md +147 -0
  182. package/skills/init-project-light/skill.md +139 -0
  183. package/skills/init-project-research/SKILL.md +368 -0
  184. package/skills/init-project-research/references/atlas-pipeline-sync.md +70 -0
  185. package/skills/init-project-research/references/atlas-schema.md +81 -0
  186. package/skills/init-project-research/references/confirmation-report.md +39 -0
  187. package/skills/init-project-research/references/domain-profile-template.md +104 -0
  188. package/skills/init-project-research/references/interview-round3.md +34 -0
  189. package/skills/init-project-research/references/literature-discovery.md +43 -0
  190. package/skills/init-project-research/references/scaffold-details.md +197 -0
  191. package/skills/init-project-research/templates/field-calibration.md +60 -0
  192. package/skills/init-project-research/templates/pipeline-manifest.md +63 -0
  193. package/skills/init-project-research/templates/run-all.sh +116 -0
  194. package/skills/init-project-research/templates/seed-files.md +337 -0
  195. package/skills/insights-deck/SKILL.md +151 -0
  196. package/skills/interview-me/SKILL.md +157 -0
  197. package/skills/latex/SKILL.md +141 -0
  198. package/skills/latex/references/latex-configs.md +183 -0
  199. package/skills/latex-autofix/SKILL.md +230 -0
  200. package/skills/latex-autofix/references/known-errors.md +183 -0
  201. package/skills/latex-autofix/references/quality-rubric.md +50 -0
  202. package/skills/latex-health-check/SKILL.md +161 -0
  203. package/skills/learn/SKILL.md +220 -0
  204. package/skills/learn/scripts/validate_skill.py +265 -0
  205. package/skills/lessons-learned/SKILL.md +201 -0
  206. package/skills/literature/SKILL.md +335 -0
  207. package/skills/literature/references/agent-templates.md +393 -0
  208. package/skills/literature/references/bibliometric-apis.md +44 -0
  209. package/skills/literature/references/cli-council-search.md +79 -0
  210. package/skills/literature/references/openalex-api-guide.md +371 -0
  211. package/skills/literature/references/openalex-common-queries.md +381 -0
  212. package/skills/literature/references/openalex-workflows.md +248 -0
  213. package/skills/literature/references/reference-manager-sync.md +36 -0
  214. package/skills/literature/references/scopus-api-guide.md +208 -0
  215. package/skills/literature/references/wos-api-guide.md +308 -0
  216. package/skills/multi-perspective/SKILL.md +311 -0
  217. package/skills/multi-perspective/references/computational-many-analysts.md +77 -0
  218. package/skills/pipeline-manifest/SKILL.md +226 -0
  219. package/skills/pre-submission-report/SKILL.md +153 -0
  220. package/skills/process-reviews/SKILL.md +244 -0
  221. package/skills/process-reviews/references/rr-routing.md +101 -0
  222. package/skills/project-deck/SKILL.md +87 -0
  223. package/skills/project-safety/SKILL.md +135 -0
  224. package/skills/proofread/SKILL.md +254 -0
  225. package/skills/proofread/references/quality-rubric.md +104 -0
  226. package/skills/python-env/SKILL.md +57 -0
  227. package/skills/quarto-deck/SKILL.md +226 -0
  228. package/skills/quarto-deck/references/markdown-format.md +143 -0
  229. package/skills/quarto-deck/references/quality-rubric.md +54 -0
  230. package/skills/save-context/SKILL.md +174 -0
  231. package/skills/session-log/SKILL.md +98 -0
  232. package/skills/shared/concept-validation-gate.md +161 -0
  233. package/skills/shared/council-protocol.md +265 -0
  234. package/skills/shared/distribution-diagnostics.md +164 -0
  235. package/skills/shared/engagement-stratified-sampling.md +218 -0
  236. package/skills/shared/escalation-protocol.md +74 -0
  237. package/skills/shared/external-audit-protocol.md +205 -0
  238. package/skills/shared/intercoder-reliability.md +256 -0
  239. package/skills/shared/mcp-degradation.md +81 -0
  240. package/skills/shared/method-probing-questions.md +163 -0
  241. package/skills/shared/multi-language-conventions.md +143 -0
  242. package/skills/shared/paid-api-safety.md +174 -0
  243. package/skills/shared/palettes.md +90 -0
  244. package/skills/shared/progressive-disclosure.md +92 -0
  245. package/skills/shared/project-documentation-content.md +443 -0
  246. package/skills/shared/project-documentation-format.md +281 -0
  247. package/skills/shared/project-documentation.md +100 -0
  248. package/skills/shared/publication-output.md +138 -0
  249. package/skills/shared/quality-scoring.md +70 -0
  250. package/skills/shared/reference-resolution.md +77 -0
  251. package/skills/shared/research-quality-rubric.md +165 -0
  252. package/skills/shared/rhetoric-principles.md +54 -0
  253. package/skills/shared/skill-design-patterns.md +272 -0
  254. package/skills/shared/skill-index.md +240 -0
  255. package/skills/shared/system-documentation.md +334 -0
  256. package/skills/shared/tikz-rules.md +402 -0
  257. package/skills/shared/validation-tiers.md +121 -0
  258. package/skills/shared/venue-guides/README.md +46 -0
  259. package/skills/shared/venue-guides/cell_press_style.md +483 -0
  260. package/skills/shared/venue-guides/conferences_formatting.md +564 -0
  261. package/skills/shared/venue-guides/cs_conference_style.md +463 -0
  262. package/skills/shared/venue-guides/examples/cell_summary_example.md +247 -0
  263. package/skills/shared/venue-guides/examples/medical_structured_abstract.md +313 -0
  264. package/skills/shared/venue-guides/examples/nature_abstract_examples.md +213 -0
  265. package/skills/shared/venue-guides/examples/neurips_introduction_example.md +245 -0
  266. package/skills/shared/venue-guides/journals_formatting.md +486 -0
  267. package/skills/shared/venue-guides/medical_journal_styles.md +535 -0
  268. package/skills/shared/venue-guides/ml_conference_style.md +556 -0
  269. package/skills/shared/venue-guides/nature_science_style.md +405 -0
  270. package/skills/shared/venue-guides/reviewer_expectations.md +417 -0
  271. package/skills/shared/venue-guides/venue_writing_styles.md +321 -0
  272. package/skills/split-pdf/SKILL.md +172 -0
  273. package/skills/split-pdf/methodology.md +48 -0
  274. package/skills/sync-notion/SKILL.md +93 -0
  275. package/skills/system-audit/SKILL.md +157 -0
  276. package/skills/system-audit/references/sub-agent-prompts.md +294 -0
  277. package/skills/task-management/SKILL.md +131 -0
  278. package/skills/update-focus/SKILL.md +204 -0
  279. package/skills/update-project-doc/SKILL.md +194 -0
  280. package/skills/validate-bib/SKILL.md +242 -0
  281. package/skills/validate-bib/references/council-mode.md +34 -0
  282. package/skills/validate-bib/references/deep-verify.md +71 -0
  283. package/skills/validate-bib/references/openalex-verification.md +45 -0
  284. package/skills/validate-bib/references/preprint-check.md +31 -0
  285. package/skills/validate-bib/references/report-template.md +62 -0
@@ -0,0 +1,259 @@
1
+ """CLI entry point for running council deliberations.
2
+
3
+ Usage:
4
+ # Run a council
5
+ llm-council \\
6
+ --system-prompt "You are a paper reviewer..." \\
7
+ --user-message "Review this paper: ..." \\
8
+ --models "anthropic/claude-sonnet-4.5,openai/gpt-4.1,google/gemini-2.5-pro" \\
9
+ --chairman "anthropic/claude-sonnet-4.5" \\
10
+ --output result.json
11
+
12
+ # Or read prompts from files:
13
+ llm-council \\
14
+ --system-prompt-file system.txt \\
15
+ --user-message-file user.txt
16
+
17
+ # Manage models
18
+ llm-council models # show available + defaults
19
+ llm-council models --pricing # include OpenRouter pricing
20
+ llm-council models --set-defaults "m1,m2" # set default council models
21
+ llm-council models --set-chairman "m1" # set default chairman
22
+ llm-council models --reset # revert to built-in defaults
23
+
24
+ Environment:
25
+ OPENROUTER_API_KEY Required for council runs and pricing lookups.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import argparse
31
+ import asyncio
32
+ import json
33
+ import os
34
+ import sys
35
+
36
+ from llm_council.client import LLMClient
37
+ from llm_council.config import (
38
+ AVAILABLE_MODELS,
39
+ USER_CONFIG_PATH,
40
+ _BUILTIN_DEFAULT_CHAIRMAN,
41
+ _BUILTIN_DEFAULT_MODELS,
42
+ get_chairman_default,
43
+ get_council_defaults,
44
+ reset_council_defaults,
45
+ set_council_defaults,
46
+ )
47
+
48
+
49
+ # --- Models subcommand ---
50
+
51
+
52
+ async def _models_command(args: argparse.Namespace) -> None:
53
+ if args.reset:
54
+ reset_council_defaults()
55
+ print("Reset to built-in defaults.")
56
+ print(f" Council: {', '.join(_BUILTIN_DEFAULT_MODELS)}")
57
+ print(f" Chairman: {_BUILTIN_DEFAULT_CHAIRMAN}")
58
+ return
59
+
60
+ if args.set_defaults:
61
+ models = [m.strip() for m in args.set_defaults.split(",")]
62
+ known_ids = {m["id"] for m in AVAILABLE_MODELS}
63
+ unknown = [m for m in models if m not in known_ids]
64
+ if unknown:
65
+ print(f"Warning: unknown model(s): {', '.join(unknown)}", file=sys.stderr)
66
+ print("They will be saved but may not work on OpenRouter.", file=sys.stderr)
67
+ set_council_defaults(models=models)
68
+ print(f"Default council models set: {', '.join(models)}")
69
+
70
+ if args.set_chairman:
71
+ set_council_defaults(chairman=args.set_chairman)
72
+ print(f"Default chairman set: {args.set_chairman}")
73
+
74
+ if args.set_defaults or args.set_chairman:
75
+ return
76
+
77
+ # List models
78
+ council_defaults = get_council_defaults()
79
+ chairman = get_chairman_default()
80
+ is_custom = USER_CONFIG_PATH.exists()
81
+
82
+ if args.pricing:
83
+ from llm_council.config import fetch_model_pricing
84
+
85
+ models = await fetch_model_pricing()
86
+ else:
87
+ models = [m.copy() for m in AVAILABLE_MODELS]
88
+
89
+ # Group by provider
90
+ providers: dict[str, list[dict]] = {}
91
+ for m in models:
92
+ provider = m["id"].split("/")[0]
93
+ providers.setdefault(provider, []).append(m)
94
+
95
+ source = "user config" if is_custom else "built-in"
96
+ print(f"Council defaults ({source}):")
97
+ print(f" Models: {', '.join(council_defaults)}")
98
+ print(f" Chairman: {chairman}")
99
+ if is_custom:
100
+ print(f" Config: {USER_CONFIG_PATH}")
101
+ print()
102
+
103
+ print(f"Available models ({len(models)}):")
104
+ for provider in ["anthropic", "openai", "google"]:
105
+ if provider not in providers:
106
+ continue
107
+ print(f"\n {provider.title()}:")
108
+ for m in providers[provider]:
109
+ marker = ""
110
+ if m["id"] in council_defaults and m["id"] == chairman:
111
+ marker = " [default, chairman]"
112
+ elif m["id"] in council_defaults:
113
+ marker = " [default]"
114
+ elif m["id"] == chairman:
115
+ marker = " [chairman]"
116
+
117
+ price = ""
118
+ if args.pricing and "input_price" in m:
119
+ price = f" ({m['input_price']}/{m['output_price']} per 1M tok)"
120
+
121
+ print(f" {m['id']:45s} {m.get('tier', ''):25s}{marker}{price}")
122
+
123
+
124
+ # --- Run subcommand (existing behavior) ---
125
+
126
+
127
+ async def _run_command(args: argparse.Namespace) -> None:
128
+ api_key = os.environ.get("OPENROUTER_API_KEY", "")
129
+ if not api_key:
130
+ print("Error: OPENROUTER_API_KEY environment variable not set.", file=sys.stderr)
131
+ sys.exit(1)
132
+
133
+ if args.system_prompt_file:
134
+ system_prompt = open(args.system_prompt_file).read()
135
+ else:
136
+ system_prompt = args.system_prompt or "You are a helpful expert assistant."
137
+
138
+ if args.user_message_file:
139
+ user_msg = open(args.user_message_file).read()
140
+ else:
141
+ user_msg = args.user_message or ""
142
+
143
+ if not user_msg:
144
+ print("Error: --user-message or --user-message-file required.", file=sys.stderr)
145
+ sys.exit(1)
146
+
147
+ models = [m.strip() for m in args.models.split(",")]
148
+ chairman = args.chairman
149
+
150
+ from llm_council.council import CouncilService
151
+
152
+ llm = LLMClient(api_key=api_key, max_tokens=args.max_tokens)
153
+ council = CouncilService(llm)
154
+
155
+ try:
156
+ result = await council.run_council(
157
+ system_prompt=system_prompt,
158
+ user_msg=user_msg,
159
+ council_models=models,
160
+ chairman_model=chairman,
161
+ )
162
+
163
+ output = result.model_dump()
164
+
165
+ if args.output:
166
+ with open(args.output, "w") as f:
167
+ json.dump(output, f, indent=2)
168
+ print(f"Result written to {args.output}", file=sys.stderr)
169
+ else:
170
+ print(json.dumps(output, indent=2))
171
+
172
+ finally:
173
+ await llm.close()
174
+
175
+
176
+ def main() -> None:
177
+ parser = argparse.ArgumentParser(
178
+ description="Multi-model LLM council via OpenRouter.",
179
+ )
180
+ subparsers = parser.add_subparsers(dest="command")
181
+
182
+ # --- models subcommand ---
183
+ models_parser = subparsers.add_parser(
184
+ "models", help="List available models and manage defaults.",
185
+ )
186
+ models_parser.add_argument(
187
+ "--pricing", action="store_true",
188
+ help="Fetch and display OpenRouter pricing.",
189
+ )
190
+ models_parser.add_argument(
191
+ "--set-defaults", type=str, metavar="MODELS",
192
+ help="Set default council models (comma-separated model IDs).",
193
+ )
194
+ models_parser.add_argument(
195
+ "--set-chairman", type=str, metavar="MODEL",
196
+ help="Set default chairman model.",
197
+ )
198
+ models_parser.add_argument(
199
+ "--reset", action="store_true",
200
+ help="Remove user config and revert to built-in defaults.",
201
+ )
202
+
203
+ # --- run subcommand (also the default) ---
204
+ run_parser = subparsers.add_parser(
205
+ "run", help="Run a council deliberation.",
206
+ )
207
+ _add_run_args(run_parser)
208
+
209
+ # Also add run args to the main parser for backwards compatibility
210
+ _add_run_args(parser)
211
+
212
+ args = parser.parse_args()
213
+
214
+ if args.command == "models":
215
+ asyncio.run(_models_command(args))
216
+ else:
217
+ asyncio.run(_run_command(args))
218
+
219
+
220
+ def _add_run_args(parser: argparse.ArgumentParser) -> None:
221
+ """Add the run-command arguments to a parser."""
222
+ parser.add_argument(
223
+ "--system-prompt", type=str, default=None,
224
+ help="System prompt for Stage 1 assessments.",
225
+ )
226
+ parser.add_argument(
227
+ "--system-prompt-file", type=str, default=None,
228
+ help="Read system prompt from a file.",
229
+ )
230
+ parser.add_argument(
231
+ "--user-message", type=str, default=None,
232
+ help="User message for Stage 1 assessments.",
233
+ )
234
+ parser.add_argument(
235
+ "--user-message-file", type=str, default=None,
236
+ help="Read user message from a file.",
237
+ )
238
+ parser.add_argument(
239
+ "--models", type=str,
240
+ default=",".join(get_council_defaults()),
241
+ help="Comma-separated list of OpenRouter model IDs.",
242
+ )
243
+ parser.add_argument(
244
+ "--chairman", type=str,
245
+ default=get_chairman_default(),
246
+ help="Model ID for the chairman synthesis.",
247
+ )
248
+ parser.add_argument(
249
+ "--max-tokens", type=int, default=4096,
250
+ help="Max tokens per LLM response.",
251
+ )
252
+ parser.add_argument(
253
+ "--output", "-o", type=str, default=None,
254
+ help="Write JSON result to file instead of stdout.",
255
+ )
256
+
257
+
258
+ if __name__ == "__main__":
259
+ main()
@@ -0,0 +1,193 @@
1
+ """Checkpoint and file-based coordination for council runs.
2
+
3
+ Inspired by:
4
+ - Owlex (agentic-mcp-tools/owlex): session resumption across deliberation rounds
5
+ - agents-council (MrLesk/agents-council): atomic file writes, cursor-based state
6
+
7
+ Provides:
8
+ - Atomic JSON writes (tmp + fsync + rename) for crash safety
9
+ - Stage checkpointing: save after each stage, resume from last completed stage
10
+ - Pending participant tracking
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import logging
17
+ import os
18
+ import tempfile
19
+ from dataclasses import dataclass, field
20
+ from datetime import datetime, timezone
21
+ from pathlib import Path
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ DEFAULT_CHECKPOINT_DIR = ".council"
26
+
27
+
28
+ def _atomic_write_json(path: Path, data: dict) -> None:
29
+ """Write JSON atomically: tmp file -> fsync -> rename.
30
+
31
+ If the process crashes mid-write, the original file is preserved.
32
+ """
33
+ path.parent.mkdir(parents=True, exist_ok=True)
34
+ fd, tmp_path = tempfile.mkstemp(
35
+ dir=str(path.parent), suffix=".tmp", prefix=".ckpt-",
36
+ )
37
+ try:
38
+ with os.fdopen(fd, "w") as f:
39
+ json.dump(data, f, indent=2, default=str)
40
+ f.flush()
41
+ os.fsync(f.fileno())
42
+ os.rename(tmp_path, str(path))
43
+ except BaseException:
44
+ try:
45
+ os.unlink(tmp_path)
46
+ except OSError:
47
+ pass
48
+ raise
49
+
50
+
51
+ def _read_json(path: Path) -> dict | None:
52
+ """Read a JSON checkpoint file, returning None if missing or corrupt."""
53
+ if not path.exists():
54
+ return None
55
+ try:
56
+ return json.loads(path.read_text())
57
+ except (json.JSONDecodeError, OSError) as exc:
58
+ logger.warning("Corrupt checkpoint %s: %s", path, exc)
59
+ return None
60
+
61
+
62
+ def _make_run_id() -> str:
63
+ """Generate a timestamped run ID."""
64
+ return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
65
+
66
+
67
+ @dataclass
68
+ class CouncilCheckpointer:
69
+ """Manages checkpoint state for a council run."""
70
+
71
+ checkpoint_dir: Path
72
+ run_id: str = field(default_factory=_make_run_id)
73
+
74
+ def __post_init__(self) -> None:
75
+ self.checkpoint_dir = Path(self.checkpoint_dir)
76
+
77
+ # ---- Save ----
78
+
79
+ def save_stage1(self, assessments: list[dict], models: list[str]) -> Path:
80
+ """Save Stage 1 assessments to checkpoint."""
81
+ path = self._stage_path(1)
82
+ data = {
83
+ "meta": {
84
+ "run_id": self.run_id,
85
+ "stage": 1,
86
+ "timestamp": datetime.now(timezone.utc).isoformat(),
87
+ "models": models,
88
+ },
89
+ "assessments": assessments,
90
+ }
91
+ _atomic_write_json(path, data)
92
+ logger.info("Checkpoint saved: Stage 1 → %s", path)
93
+ return path
94
+
95
+ def save_stage2(
96
+ self,
97
+ peer_reviews: list[dict],
98
+ models: list[str],
99
+ aggregate_rankings: list[dict] | None = None,
100
+ ) -> Path:
101
+ """Save Stage 2 peer reviews to checkpoint."""
102
+ path = self._stage_path(2)
103
+ data = {
104
+ "meta": {
105
+ "run_id": self.run_id,
106
+ "stage": 2,
107
+ "timestamp": datetime.now(timezone.utc).isoformat(),
108
+ "models": models,
109
+ },
110
+ "peer_reviews": peer_reviews,
111
+ "aggregate_rankings": aggregate_rankings or [],
112
+ }
113
+ _atomic_write_json(path, data)
114
+ logger.info("Checkpoint saved: Stage 2 → %s", path)
115
+ return path
116
+
117
+ def save_stage3(self, synthesis: dict | str, chairman: str) -> Path:
118
+ """Save Stage 3 synthesis to checkpoint."""
119
+ path = self._stage_path(3)
120
+ data = {
121
+ "meta": {
122
+ "run_id": self.run_id,
123
+ "stage": 3,
124
+ "timestamp": datetime.now(timezone.utc).isoformat(),
125
+ "chairman": chairman,
126
+ },
127
+ "synthesis": synthesis,
128
+ }
129
+ _atomic_write_json(path, data)
130
+ logger.info("Checkpoint saved: Stage 3 → %s", path)
131
+ return path
132
+
133
+ # ---- Load / Resume ----
134
+
135
+ def last_completed_stage(self) -> int:
136
+ """Return the highest completed stage (0 if none)."""
137
+ for stage in (3, 2, 1):
138
+ if self._stage_path(stage).exists():
139
+ return stage
140
+ return 0
141
+
142
+ def load_stage1(self) -> list[dict] | None:
143
+ """Load Stage 1 assessments from checkpoint."""
144
+ data = _read_json(self._stage_path(1))
145
+ if data is None:
146
+ return None
147
+ return data.get("assessments")
148
+
149
+ def load_stage2(self) -> tuple[list[dict], list[dict]] | None:
150
+ """Load Stage 2 peer reviews + aggregate rankings from checkpoint."""
151
+ data = _read_json(self._stage_path(2))
152
+ if data is None:
153
+ return None
154
+ return data.get("peer_reviews", []), data.get("aggregate_rankings", [])
155
+
156
+ def load_stage3(self) -> dict | str | None:
157
+ """Load Stage 3 synthesis from checkpoint."""
158
+ data = _read_json(self._stage_path(3))
159
+ if data is None:
160
+ return None
161
+ return data.get("synthesis")
162
+
163
+ # ---- Pending participants ----
164
+
165
+ def pending_participants(
166
+ self, all_models: list[str], responded: list[str],
167
+ ) -> list[str]:
168
+ """Return models that haven't responded yet."""
169
+ return [m for m in all_models if m not in set(responded)]
170
+
171
+ # ---- Discovery ----
172
+
173
+ def find_latest_run(self) -> str | None:
174
+ """Find the most recent run_id in the checkpoint directory."""
175
+ if not self.checkpoint_dir.exists():
176
+ return None
177
+ stage1_files = sorted(self.checkpoint_dir.glob("*-stage1.json"), reverse=True)
178
+ if not stage1_files:
179
+ return None
180
+ return stage1_files[0].stem.replace("-stage1", "")
181
+
182
+ def clean(self) -> None:
183
+ """Remove all checkpoint files for this run."""
184
+ for stage in (1, 2, 3):
185
+ path = self._stage_path(stage)
186
+ if path.exists():
187
+ path.unlink()
188
+ logger.info("Removed checkpoint: %s", path)
189
+
190
+ # ---- Internal ----
191
+
192
+ def _stage_path(self, stage: int) -> Path:
193
+ return self.checkpoint_dir / f"{self.run_id}-stage{stage}.json"
@@ -0,0 +1,253 @@
1
+ """Generic OpenRouter LLM client.
2
+
3
+ OpenRouter wrapper with JSON parsing, retry logic, and error handling.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import logging
10
+ import re
11
+
12
+ import openai
13
+ from openai import AsyncOpenAI
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
18
+ OPENROUTER_CREDITS_URL = "https://openrouter.ai/credits"
19
+
20
+
21
+ class LLMResponseFormatError(RuntimeError):
22
+ """Raised when an LLM response cannot be parsed into valid JSON."""
23
+
24
+
25
+ class LLMServiceError(Exception):
26
+ """User-friendly error from the LLM service."""
27
+
28
+ def __init__(
29
+ self,
30
+ message: str,
31
+ *,
32
+ help_url: str | None = None,
33
+ detail: str | None = None,
34
+ ) -> None:
35
+ super().__init__(message)
36
+ self.help_url = help_url
37
+ self.detail = detail
38
+
39
+
40
+ def _handle_openai_error(exc: Exception) -> LLMServiceError:
41
+ """Convert openai SDK exceptions into user-friendly LLMServiceError."""
42
+ if isinstance(exc, openai.AuthenticationError):
43
+ return LLMServiceError(
44
+ "Invalid OpenRouter API key. Please check your OPENROUTER_API_KEY.",
45
+ help_url=OPENROUTER_CREDITS_URL,
46
+ detail=str(exc),
47
+ )
48
+ if isinstance(exc, openai.RateLimitError):
49
+ return LLMServiceError(
50
+ "OpenRouter rate limit reached. Please wait a moment and try again.",
51
+ help_url=OPENROUTER_CREDITS_URL,
52
+ detail=str(exc),
53
+ )
54
+ if isinstance(exc, openai.APIStatusError):
55
+ status = getattr(exc, "status_code", None)
56
+ if status == 402:
57
+ return LLMServiceError(
58
+ "Insufficient OpenRouter credits. Please top up your account.",
59
+ help_url=OPENROUTER_CREDITS_URL,
60
+ detail=str(exc),
61
+ )
62
+ if status == 503:
63
+ return LLMServiceError(
64
+ "The selected LLM model is temporarily unavailable. Try a different model.",
65
+ detail=str(exc),
66
+ )
67
+ return LLMServiceError(
68
+ f"OpenRouter API error (HTTP {status}). Please try again.",
69
+ help_url=OPENROUTER_CREDITS_URL,
70
+ detail=str(exc),
71
+ )
72
+ if isinstance(exc, openai.APIConnectionError):
73
+ return LLMServiceError(
74
+ "Could not connect to OpenRouter. Please check your internet connection.",
75
+ detail=str(exc),
76
+ )
77
+ return LLMServiceError(
78
+ "LLM request failed unexpectedly. Please try again.",
79
+ detail=str(exc),
80
+ )
81
+
82
+
83
+ class LLMClient:
84
+ """Generic async LLM client via OpenRouter.
85
+
86
+ Provides structured JSON and raw text chat methods with retry logic.
87
+ Consumers extend this with domain-specific workflow methods.
88
+ """
89
+
90
+ def __init__(
91
+ self,
92
+ api_key: str,
93
+ model: str = "anthropic/claude-sonnet-4.5",
94
+ max_tokens: int = 4096,
95
+ json_retry_attempts: int = 2,
96
+ ) -> None:
97
+ self.client = AsyncOpenAI(
98
+ api_key=api_key,
99
+ base_url=OPENROUTER_BASE_URL,
100
+ )
101
+ self.model = model
102
+ self.max_tokens = max_tokens
103
+ self.json_retry_attempts = max(1, json_retry_attempts)
104
+
105
+ async def chat_json(
106
+ self,
107
+ system: str,
108
+ user_msg: str,
109
+ *,
110
+ model: str | None = None,
111
+ max_tokens: int | None = None,
112
+ ) -> dict:
113
+ """Send a message and parse a JSON-object response with retries.
114
+
115
+ Parameters
116
+ ----------
117
+ max_tokens:
118
+ Override the client's default ``max_tokens`` for this call.
119
+ Useful when the expected JSON response is larger than usual
120
+ (e.g. council synthesis of a discovery workflow).
121
+ """
122
+ effective_model = model or self.model
123
+ effective_max_tokens = max_tokens or self.max_tokens
124
+ prompt = user_msg + "\n\nRespond ONLY with valid JSON."
125
+ raw_text = ""
126
+ parse_error: Exception | None = None
127
+
128
+ for attempt in range(1, self.json_retry_attempts + 1):
129
+ try:
130
+ response = await self.client.chat.completions.create(
131
+ model=effective_model,
132
+ max_tokens=effective_max_tokens,
133
+ messages=[
134
+ {"role": "system", "content": system},
135
+ {"role": "user", "content": prompt},
136
+ ],
137
+ )
138
+ except (openai.APIError, openai.APIConnectionError) as api_exc:
139
+ raise _handle_openai_error(api_exc) from api_exc
140
+
141
+ raw_text = (response.choices[0].message.content or "").strip()
142
+
143
+ try:
144
+ return self._parse_json_response(raw_text)
145
+ except LLMResponseFormatError as exc:
146
+ parse_error = exc
147
+ logger.warning(
148
+ "LLM JSON parse failed (attempt %d/%d): %s",
149
+ attempt, self.json_retry_attempts, exc,
150
+ )
151
+ if attempt == self.json_retry_attempts:
152
+ break
153
+ prompt = (
154
+ "Your previous response was not valid JSON.\n"
155
+ "Return ONLY a valid JSON object matching the schema in the system prompt.\n"
156
+ "Do not include markdown fences or commentary.\n\n"
157
+ "Previous invalid response:\n"
158
+ f"{raw_text[:8000]}"
159
+ )
160
+
161
+ snippet = raw_text[:240].replace("\n", " ")
162
+ raise LLMResponseFormatError(
163
+ f"Failed to parse LLM JSON response after {self.json_retry_attempts} attempts. "
164
+ f"Last parse error: {parse_error}. Response snippet: {snippet!r}"
165
+ )
166
+
167
+ async def chat_text(
168
+ self,
169
+ system: str,
170
+ user_msg: str,
171
+ *,
172
+ model: str | None = None,
173
+ max_tokens: int | None = None,
174
+ ) -> str:
175
+ """Query the LLM and return the raw text response (no JSON parsing).
176
+
177
+ Used for Stage 2 peer review which returns free-form text with a
178
+ FINAL RANKING section.
179
+
180
+ Parameters
181
+ ----------
182
+ max_tokens:
183
+ Override the client's default ``max_tokens`` for this call.
184
+ """
185
+ effective_model = model or self.model
186
+ effective_max_tokens = max_tokens or self.max_tokens
187
+ try:
188
+ response = await self.client.chat.completions.create(
189
+ model=effective_model,
190
+ max_tokens=effective_max_tokens,
191
+ messages=[
192
+ {"role": "system", "content": system},
193
+ {"role": "user", "content": user_msg},
194
+ ],
195
+ )
196
+ except (openai.APIError, openai.APIConnectionError) as api_exc:
197
+ raise _handle_openai_error(api_exc) from api_exc
198
+ return (response.choices[0].message.content or "").strip()
199
+
200
+ async def close(self) -> None:
201
+ await self.client.close()
202
+
203
+ # ------------------------------------------------------------------
204
+ # JSON parsing utilities
205
+ # ------------------------------------------------------------------
206
+
207
+ @staticmethod
208
+ def _extract_json_candidates(text: str) -> list[str]:
209
+ stripped = text.strip()
210
+ candidates: list[str] = []
211
+
212
+ if stripped:
213
+ candidates.append(stripped)
214
+
215
+ fenced_blocks = re.findall(
216
+ r"```(?:json)?\s*(.*?)```", stripped, flags=re.IGNORECASE | re.DOTALL,
217
+ )
218
+ for block in fenced_blocks:
219
+ block = block.strip()
220
+ if block:
221
+ candidates.append(block)
222
+
223
+ first_brace = stripped.find("{")
224
+ last_brace = stripped.rfind("}")
225
+ if first_brace != -1 and last_brace != -1 and last_brace > first_brace:
226
+ candidates.append(stripped[first_brace : last_brace + 1].strip())
227
+
228
+ deduped: list[str] = []
229
+ seen: set[str] = set()
230
+ for candidate in candidates:
231
+ if candidate not in seen:
232
+ seen.add(candidate)
233
+ deduped.append(candidate)
234
+ return deduped
235
+
236
+ @classmethod
237
+ def _parse_json_response(cls, text: str) -> dict:
238
+ errors: list[str] = []
239
+ for candidate in cls._extract_json_candidates(text):
240
+ try:
241
+ parsed = json.loads(candidate)
242
+ except json.JSONDecodeError as exc:
243
+ errors.append(f"{exc.msg} (line {exc.lineno}, col {exc.colno})")
244
+ continue
245
+ if isinstance(parsed, dict):
246
+ return parsed
247
+ errors.append(f"Expected JSON object, got {type(parsed).__name__}")
248
+
249
+ snippet = text[:240].replace("\n", " ")
250
+ raise LLMResponseFormatError(
251
+ f"Unable to parse JSON object from response. Snippet: {snippet!r}. "
252
+ f"Errors: {errors[:2]}"
253
+ )