npcsh 1.1.20__py3-none-any.whl → 1.1.22__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 (186) hide show
  1. npcsh/_state.py +15 -76
  2. npcsh/benchmark/npcsh_agent.py +22 -14
  3. npcsh/benchmark/templates/install-npcsh.sh.j2 +2 -2
  4. npcsh/diff_viewer.py +3 -3
  5. npcsh/mcp_server.py +9 -1
  6. npcsh/npc_team/alicanto.npc +12 -6
  7. npcsh/npc_team/corca.npc +0 -1
  8. npcsh/npc_team/frederic.npc +2 -3
  9. npcsh/npc_team/jinxs/lib/core/compress.jinx +373 -85
  10. npcsh/npc_team/jinxs/lib/core/edit_file.jinx +83 -61
  11. npcsh/npc_team/jinxs/lib/core/search/db_search.jinx +17 -6
  12. npcsh/npc_team/jinxs/lib/core/search/file_search.jinx +17 -6
  13. npcsh/npc_team/jinxs/lib/core/search/web_search.jinx +52 -14
  14. npcsh/npc_team/jinxs/{bin → lib/utils}/benchmark.jinx +2 -2
  15. npcsh/npc_team/jinxs/{bin → lib/utils}/jinxs.jinx +12 -12
  16. npcsh/npc_team/jinxs/{bin → lib/utils}/models.jinx +7 -7
  17. npcsh/npc_team/jinxs/{bin → lib/utils}/setup.jinx +6 -6
  18. npcsh/npc_team/jinxs/modes/alicanto.jinx +1633 -295
  19. npcsh/npc_team/jinxs/modes/arxiv.jinx +5 -5
  20. npcsh/npc_team/jinxs/modes/build.jinx +378 -0
  21. npcsh/npc_team/jinxs/modes/config_tui.jinx +300 -0
  22. npcsh/npc_team/jinxs/modes/convene.jinx +597 -0
  23. npcsh/npc_team/jinxs/modes/corca.jinx +777 -387
  24. npcsh/npc_team/jinxs/modes/git.jinx +795 -0
  25. {npcsh-1.1.20.data/data/npcsh/npc_team → npcsh/npc_team/jinxs/modes}/kg.jinx +82 -15
  26. npcsh/npc_team/jinxs/modes/memories.jinx +414 -0
  27. npcsh/npc_team/jinxs/{bin → modes}/nql.jinx +10 -21
  28. npcsh/npc_team/jinxs/modes/papers.jinx +578 -0
  29. npcsh/npc_team/jinxs/modes/plonk.jinx +503 -308
  30. npcsh/npc_team/jinxs/modes/reattach.jinx +3 -3
  31. npcsh/npc_team/jinxs/modes/spool.jinx +3 -3
  32. npcsh/npc_team/jinxs/{bin → modes}/team.jinx +12 -12
  33. npcsh/npc_team/jinxs/modes/vixynt.jinx +388 -0
  34. npcsh/npc_team/jinxs/modes/wander.jinx +454 -181
  35. npcsh/npc_team/jinxs/modes/yap.jinx +630 -182
  36. npcsh/npc_team/kadiefa.npc +2 -1
  37. npcsh/npc_team/sibiji.npc +3 -3
  38. npcsh/npcsh.py +112 -47
  39. npcsh/routes.py +4 -1
  40. npcsh/salmon_simulation.py +0 -0
  41. npcsh-1.1.22.data/data/npcsh/npc_team/alicanto.jinx +1694 -0
  42. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/alicanto.npc +12 -6
  43. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/arxiv.jinx +5 -5
  44. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/benchmark.jinx +2 -2
  45. npcsh-1.1.22.data/data/npcsh/npc_team/build.jinx +378 -0
  46. npcsh-1.1.22.data/data/npcsh/npc_team/compress.jinx +428 -0
  47. npcsh-1.1.22.data/data/npcsh/npc_team/config_tui.jinx +300 -0
  48. npcsh-1.1.22.data/data/npcsh/npc_team/corca.jinx +820 -0
  49. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/corca.npc +0 -1
  50. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/db_search.jinx +17 -6
  51. npcsh-1.1.22.data/data/npcsh/npc_team/edit_file.jinx +119 -0
  52. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/file_search.jinx +17 -6
  53. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/frederic.npc +2 -3
  54. npcsh-1.1.22.data/data/npcsh/npc_team/git.jinx +795 -0
  55. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/jinxs.jinx +12 -12
  56. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/kadiefa.npc +2 -1
  57. {npcsh/npc_team/jinxs/bin → npcsh-1.1.22.data/data/npcsh/npc_team}/kg.jinx +82 -15
  58. npcsh-1.1.22.data/data/npcsh/npc_team/memories.jinx +414 -0
  59. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/models.jinx +7 -7
  60. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/nql.jinx +10 -21
  61. npcsh-1.1.22.data/data/npcsh/npc_team/papers.jinx +578 -0
  62. npcsh-1.1.22.data/data/npcsh/npc_team/plonk.jinx +574 -0
  63. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/reattach.jinx +3 -3
  64. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/setup.jinx +6 -6
  65. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/sibiji.npc +3 -3
  66. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/spool.jinx +3 -3
  67. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/team.jinx +12 -12
  68. npcsh-1.1.22.data/data/npcsh/npc_team/vixynt.jinx +388 -0
  69. npcsh-1.1.22.data/data/npcsh/npc_team/wander.jinx +728 -0
  70. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/web_search.jinx +52 -14
  71. npcsh-1.1.22.data/data/npcsh/npc_team/yap.jinx +716 -0
  72. {npcsh-1.1.20.dist-info → npcsh-1.1.22.dist-info}/METADATA +246 -281
  73. npcsh-1.1.22.dist-info/RECORD +240 -0
  74. npcsh-1.1.22.dist-info/entry_points.txt +11 -0
  75. npcsh/npc_team/jinxs/bin/config_tui.jinx +0 -300
  76. npcsh/npc_team/jinxs/bin/memories.jinx +0 -317
  77. npcsh/npc_team/jinxs/bin/vixynt.jinx +0 -122
  78. npcsh/npc_team/jinxs/lib/core/search/kg_search.jinx +0 -418
  79. npcsh/npc_team/jinxs/lib/core/search/mem_review.jinx +0 -73
  80. npcsh/npc_team/jinxs/lib/core/search/mem_search.jinx +0 -388
  81. npcsh/npc_team/jinxs/lib/core/search.jinx +0 -54
  82. npcsh/npc_team/jinxs/lib/research/paper_search.jinx +0 -412
  83. npcsh/npc_team/jinxs/lib/research/semantic_scholar.jinx +0 -386
  84. npcsh/npc_team/jinxs/lib/utils/build.jinx +0 -65
  85. npcsh/npc_team/plonkjr.npc +0 -23
  86. npcsh-1.1.20.data/data/npcsh/npc_team/alicanto.jinx +0 -356
  87. npcsh-1.1.20.data/data/npcsh/npc_team/build.jinx +0 -65
  88. npcsh-1.1.20.data/data/npcsh/npc_team/compress.jinx +0 -140
  89. npcsh-1.1.20.data/data/npcsh/npc_team/config_tui.jinx +0 -300
  90. npcsh-1.1.20.data/data/npcsh/npc_team/corca.jinx +0 -430
  91. npcsh-1.1.20.data/data/npcsh/npc_team/edit_file.jinx +0 -97
  92. npcsh-1.1.20.data/data/npcsh/npc_team/kg_search.jinx +0 -418
  93. npcsh-1.1.20.data/data/npcsh/npc_team/mem_review.jinx +0 -73
  94. npcsh-1.1.20.data/data/npcsh/npc_team/mem_search.jinx +0 -388
  95. npcsh-1.1.20.data/data/npcsh/npc_team/memories.jinx +0 -317
  96. npcsh-1.1.20.data/data/npcsh/npc_team/paper_search.jinx +0 -412
  97. npcsh-1.1.20.data/data/npcsh/npc_team/plonk.jinx +0 -379
  98. npcsh-1.1.20.data/data/npcsh/npc_team/plonkjr.npc +0 -23
  99. npcsh-1.1.20.data/data/npcsh/npc_team/search.jinx +0 -54
  100. npcsh-1.1.20.data/data/npcsh/npc_team/semantic_scholar.jinx +0 -386
  101. npcsh-1.1.20.data/data/npcsh/npc_team/vixynt.jinx +0 -122
  102. npcsh-1.1.20.data/data/npcsh/npc_team/wander.jinx +0 -455
  103. npcsh-1.1.20.data/data/npcsh/npc_team/yap.jinx +0 -268
  104. npcsh-1.1.20.dist-info/RECORD +0 -248
  105. npcsh-1.1.20.dist-info/entry_points.txt +0 -25
  106. /npcsh/npc_team/jinxs/lib/{orchestration → core}/convene.jinx +0 -0
  107. /npcsh/npc_team/jinxs/lib/{orchestration → core}/delegate.jinx +0 -0
  108. /npcsh/npc_team/jinxs/{bin → lib/core}/sample.jinx +0 -0
  109. /npcsh/npc_team/jinxs/lib/{core → utils}/chat.jinx +0 -0
  110. /npcsh/npc_team/jinxs/lib/{core → utils}/cmd.jinx +0 -0
  111. /npcsh/npc_team/jinxs/{bin → lib/utils}/sync.jinx +0 -0
  112. /npcsh/npc_team/jinxs/{bin → modes}/roll.jinx +0 -0
  113. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/add_tab.jinx +0 -0
  114. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/alicanto.png +0 -0
  115. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/browser_action.jinx +0 -0
  116. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/browser_screenshot.jinx +0 -0
  117. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/chat.jinx +0 -0
  118. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/click.jinx +0 -0
  119. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/close_browser.jinx +0 -0
  120. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/close_pane.jinx +0 -0
  121. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/close_tab.jinx +0 -0
  122. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/cmd.jinx +0 -0
  123. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/compile.jinx +0 -0
  124. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/confirm.jinx +0 -0
  125. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/convene.jinx +0 -0
  126. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/corca.png +0 -0
  127. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/corca_example.png +0 -0
  128. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/delegate.jinx +0 -0
  129. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/focus_pane.jinx +0 -0
  130. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/frederic4.png +0 -0
  131. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/guac.jinx +0 -0
  132. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/guac.npc +0 -0
  133. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/guac.png +0 -0
  134. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/help.jinx +0 -0
  135. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/incognide.jinx +0 -0
  136. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/init.jinx +0 -0
  137. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/kadiefa.png +0 -0
  138. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/key_press.jinx +0 -0
  139. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/launch_app.jinx +0 -0
  140. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/list_panes.jinx +0 -0
  141. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/load_file.jinx +0 -0
  142. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/navigate.jinx +0 -0
  143. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/notify.jinx +0 -0
  144. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/npcsh.ctx +0 -0
  145. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/npcsh_sibiji.png +0 -0
  146. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/open_browser.jinx +0 -0
  147. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/open_pane.jinx +0 -0
  148. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/ots.jinx +0 -0
  149. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/paste.jinx +0 -0
  150. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/plonk.npc +0 -0
  151. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/plonk.png +0 -0
  152. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/plonkjr.png +0 -0
  153. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/pti.jinx +0 -0
  154. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/python.jinx +0 -0
  155. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/read_pane.jinx +0 -0
  156. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/roll.jinx +0 -0
  157. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/run_terminal.jinx +0 -0
  158. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/sample.jinx +0 -0
  159. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/screenshot.jinx +0 -0
  160. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/send_message.jinx +0 -0
  161. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/serve.jinx +0 -0
  162. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/set.jinx +0 -0
  163. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/sh.jinx +0 -0
  164. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/shh.jinx +0 -0
  165. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/sibiji.png +0 -0
  166. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/sleep.jinx +0 -0
  167. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/split_pane.jinx +0 -0
  168. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/spool.png +0 -0
  169. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/sql.jinx +0 -0
  170. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/switch.jinx +0 -0
  171. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/switch_npc.jinx +0 -0
  172. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/switch_tab.jinx +0 -0
  173. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/switches.jinx +0 -0
  174. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/sync.jinx +0 -0
  175. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/teamviz.jinx +0 -0
  176. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/trigger.jinx +0 -0
  177. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/type_text.jinx +0 -0
  178. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/usage.jinx +0 -0
  179. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/verbose.jinx +0 -0
  180. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/wait.jinx +0 -0
  181. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/write_file.jinx +0 -0
  182. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/yap.png +0 -0
  183. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/zen_mode.jinx +0 -0
  184. {npcsh-1.1.20.dist-info → npcsh-1.1.22.dist-info}/WHEEL +0 -0
  185. {npcsh-1.1.20.dist-info → npcsh-1.1.22.dist-info}/licenses/LICENSE +0 -0
  186. {npcsh-1.1.20.dist-info → npcsh-1.1.22.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1694 @@
1
+ jinx_name: alicanto
2
+ description: Deep research mode - multi-agent hypothesis exploration with approval-gated TUI pipeline
3
+ interactive: true
4
+ npc: forenpc
5
+ inputs:
6
+ - query: null
7
+ - num_npcs: 3
8
+ - model: null
9
+ - provider: null
10
+ - max_steps: 10
11
+ - num_cycles: 3
12
+ - format: report
13
+
14
+ steps:
15
+ - name: alicanto_research
16
+ engine: python
17
+ code: |
18
+ import os
19
+ import sys
20
+ import tty
21
+ import termios
22
+ import select as _sel
23
+ import json
24
+ import random
25
+ import threading
26
+ import time
27
+ import textwrap
28
+ import csv
29
+ import subprocess
30
+ import hashlib
31
+ from datetime import datetime
32
+ from dataclasses import dataclass, asdict, field
33
+ from typing import List, Dict, Any, Tuple
34
+ from pathlib import Path
35
+
36
+ import requests as _requests
37
+
38
+ from npcpy.llm_funcs import get_llm_response
39
+ from npcpy.npc_compiler import NPC
40
+
41
+ try:
42
+ from npcpy.data.web import search_web
43
+ WEB_AVAILABLE = True
44
+ except ImportError:
45
+ WEB_AVAILABLE = False
46
+
47
+ try:
48
+ from npcsh.wander import perform_single_wandering
49
+ WANDER_AVAILABLE = True
50
+ except ImportError:
51
+ WANDER_AVAILABLE = False
52
+
53
+ npc = context.get('npc')
54
+ team = context.get('team')
55
+ messages = context.get('messages', [])
56
+
57
+ if isinstance(npc, str) and team:
58
+ npc = team.get(npc) if hasattr(team, 'get') else None
59
+ elif isinstance(npc, str):
60
+ npc = None
61
+
62
+ query = context.get('query')
63
+ num_npcs = int(context.get('num_npcs', 3))
64
+ max_steps = int(context.get('max_steps', 10))
65
+ num_cycles = int(context.get('num_cycles', 3))
66
+ output_format = context.get('format', 'report')
67
+
68
+ model = context.get('model') or (npc.model if npc and hasattr(npc, 'model') else None)
69
+ provider = context.get('provider') or (npc.provider if npc and hasattr(npc, 'provider') else None)
70
+ _alicanto_directive = (npc.primary_directive if npc and hasattr(npc, 'primary_directive') else "") or ""
71
+
72
+ # ========== Utility ==========
73
+ def get_size():
74
+ try:
75
+ s = os.get_terminal_size()
76
+ return s.columns, s.lines
77
+ except:
78
+ return 80, 24
79
+
80
+ def wrap_text(text, width):
81
+ lines = []
82
+ for line in str(text).split('\n'):
83
+ if len(line) <= width:
84
+ lines.append(line)
85
+ else:
86
+ lines.extend(textwrap.wrap(line, width) or [''])
87
+ return lines
88
+
89
+ def clamp(val, lo, hi):
90
+ return max(lo, min(val, hi))
91
+
92
+ # ========== Data Classes (matching original) ==========
93
+ @dataclass
94
+ class ResearchStep:
95
+ step: int
96
+ thought: str
97
+ action: str
98
+ outcome: str
99
+
100
+ @dataclass
101
+ class SubAgentTrace:
102
+ hypothesis: str
103
+ agent_name: str
104
+ agent_persona: str
105
+ steps: List[ResearchStep] = field(default_factory=list)
106
+ final_files: Dict[str, str] = field(default_factory=dict)
107
+ was_successful: bool = False
108
+
109
+ @dataclass
110
+ class Paper:
111
+ title: str = ""
112
+ abstract: str = ""
113
+ introduction: List[str] = field(default_factory=list)
114
+ methods: List[str] = field(default_factory=list)
115
+ results: List[str] = field(default_factory=list)
116
+ discussion: List[str] = field(default_factory=list)
117
+
118
+ # ========== Tool Definitions (matching original) ==========
119
+ _work_dir = os.path.join(os.getcwd(), 'alicanto_output')
120
+ os.makedirs(_work_dir, exist_ok=True)
121
+ _orig_cwd = os.getcwd()
122
+ os.chdir(_work_dir)
123
+
124
+ def create_file(filename: str, content: str) -> str:
125
+ """Create a new file with the given content."""
126
+ filepath = os.path.abspath(filename)
127
+ if os.path.exists(filepath):
128
+ return f"Error: File '{filename}' already exists. Use append_to_file or replace_in_file to modify."
129
+ os.makedirs(os.path.dirname(filepath) if os.path.dirname(filepath) else '.', exist_ok=True)
130
+ with open(filepath, 'w') as f:
131
+ f.write(content)
132
+ return f"File '{filename}' created successfully."
133
+
134
+ def append_to_file(filename: str, content: str) -> str:
135
+ """Append content to an existing file."""
136
+ filepath = os.path.abspath(filename)
137
+ if not os.path.exists(filepath):
138
+ return f"Error: File '{filename}' not found. Use create_file first."
139
+ with open(filepath, 'a') as f:
140
+ f.write("\n" + content)
141
+ return f"Content appended to '{filename}'."
142
+
143
+ def replace_in_file(filename: str, old_content: str, new_content: str) -> str:
144
+ """Replace old_content with new_content in a file."""
145
+ filepath = os.path.abspath(filename)
146
+ if not os.path.exists(filepath):
147
+ return f"Error: File '{filename}' not found."
148
+ with open(filepath, 'r') as f:
149
+ file_contents = f.read()
150
+ file_contents = file_contents.replace(old_content, new_content)
151
+ with open(filepath, 'w') as f:
152
+ f.write(file_contents)
153
+ return f"Content in '{filename}' replaced."
154
+
155
+ def read_file(filename: str) -> str:
156
+ """Read and return the contents of a file."""
157
+ filepath = os.path.abspath(filename)
158
+ if not os.path.exists(filepath):
159
+ return f"Error: File '{filename}' not found."
160
+ with open(filepath, 'r') as f:
161
+ return f.read()
162
+
163
+ def list_files(directory: str = ".") -> str:
164
+ """List files in the current directory."""
165
+ try:
166
+ return "\n".join(os.listdir(directory))
167
+ except:
168
+ return "Error listing directory."
169
+
170
+ def run_python(code: str) -> str:
171
+ """Execute Python code and return the output. This is your PRIMARY tool for data analysis, file processing, API calls, computations, and any programmatic work. Use this for: downloading data, parsing files, running analyses, making HTTP requests, processing CSVs/FITS/JSON, plotting, statistics, etc. The code runs in a fresh namespace with access to standard library and installed packages (numpy, pandas, astropy, requests, matplotlib, scipy, etc.)."""
172
+ import io as _io
173
+ _old_stdout = sys.stdout
174
+ _old_stderr = sys.stderr
175
+ _capture = _io.StringIO()
176
+ sys.stdout = _capture
177
+ sys.stderr = _capture
178
+ _ns = {'__builtins__': __builtins__}
179
+ try:
180
+ exec(code, _ns)
181
+ except Exception as _e:
182
+ print(f"Error: {type(_e).__name__}: {_e}")
183
+ finally:
184
+ sys.stdout = _old_stdout
185
+ sys.stderr = _old_stderr
186
+ return _capture.getvalue()[:5000] if _capture.getvalue().strip() else "(no output)"
187
+
188
+ def shell_command(command: str) -> str:
189
+ """Execute a shell command. ONLY use this for simple system tasks like installing packages (pip install), checking disk space, or listing system info. For ALL data work, analysis, file processing, HTTP requests, and computation, use run_python instead."""
190
+ try:
191
+ result = subprocess.run(command, shell=True, capture_output=True, text=True, timeout=60)
192
+ out = ""
193
+ if result.stdout:
194
+ out += result.stdout
195
+ if result.stderr:
196
+ out += result.stderr
197
+ return out[:3000] if out.strip() else "(no output)"
198
+ except subprocess.TimeoutExpired:
199
+ return "Command timed out (60s limit)"
200
+ except Exception as e:
201
+ return f"Error: {e}"
202
+
203
+ _search_provider = os.environ.get('NPCSH_SEARCH_PROVIDER', 'perplexity')
204
+
205
+ def _web_search_tool(query: str) -> str:
206
+ """Search the web for information."""
207
+ if not WEB_AVAILABLE:
208
+ return "Web search not available."
209
+ try:
210
+ results = search_web(query, num_results=5, provider=_search_provider)
211
+ if not results:
212
+ return "No results found."
213
+ if isinstance(results, list):
214
+ out = []
215
+ for r in results[:5]:
216
+ if isinstance(r, dict):
217
+ out.append(f"- {r.get('title', 'N/A')}: {str(r.get('content', r.get('snippet', '')))[:300]}")
218
+ else:
219
+ out.append(f"- {str(r)[:300]}")
220
+ return "\n".join(out) if out else str(results)[:2000]
221
+ return str(results)[:2000]
222
+ except Exception as e:
223
+ return f"Search error: {e}"
224
+
225
+ _s2_api_key = os.environ.get('S2_API_KEY', '')
226
+
227
+ def search_papers(query: str, limit: int = 10) -> str:
228
+ """Search Semantic Scholar for academic papers. Returns titles, authors, year, citation count, abstracts, and URLs."""
229
+ s2_url = "https://api.semanticscholar.org/graph/v1/paper/search"
230
+ params = {
231
+ "query": query,
232
+ "limit": min(limit, 20),
233
+ "fields": "title,abstract,authors,year,citationCount,url,tldr,venue"
234
+ }
235
+ try:
236
+ # Try with API key first if available
237
+ if _s2_api_key:
238
+ resp = _requests.get(s2_url, headers={"x-api-key": _s2_api_key}, params=params, timeout=30)
239
+ if resp.status_code == 403:
240
+ # Key expired/revoked, fall back to unauthenticated
241
+ resp = _requests.get(s2_url, params=params, timeout=30)
242
+ else:
243
+ resp = _requests.get(s2_url, params=params, timeout=30)
244
+ if resp.status_code == 429:
245
+ # Rate limited, wait and retry once
246
+ time.sleep(1.5)
247
+ resp = _requests.get(s2_url, params=params, timeout=30)
248
+ resp.raise_for_status()
249
+ papers = resp.json().get('data', [])
250
+ if not papers:
251
+ return f"No papers found for: {query}"
252
+ out = []
253
+ for i, p in enumerate(papers, 1):
254
+ title = p.get('title', 'No title')
255
+ year = p.get('year', '?')
256
+ cites = p.get('citationCount', 0)
257
+ authors = ', '.join([a.get('name', '') for a in p.get('authors', [])[:3]])
258
+ if len(p.get('authors', [])) > 3:
259
+ authors += ' et al.'
260
+ tldr = p.get('tldr', {}).get('text', '') if p.get('tldr') else ''
261
+ abstract = (p.get('abstract') or '')[:200]
262
+ paper_url = p.get('url', '')
263
+ venue = p.get('venue', '')
264
+ entry = f"{i}. {title} ({year}) [{cites} citations]"
265
+ entry += f"\n Authors: {authors}"
266
+ if venue:
267
+ entry += f"\n Venue: {venue}"
268
+ if tldr:
269
+ entry += f"\n TL;DR: {tldr}"
270
+ elif abstract:
271
+ entry += f"\n Abstract: {abstract}..."
272
+ entry += f"\n URL: {paper_url}"
273
+ out.append(entry)
274
+ return "\n\n".join(out)
275
+ except _requests.exceptions.RequestException as e:
276
+ return f"Semantic Scholar API error: {e}"
277
+ except Exception as e:
278
+ return f"Paper search error: {e}"
279
+
280
+ # ========== File Provenance (matching original) ==========
281
+ @dataclass
282
+ class FileProvenance:
283
+ filename: str
284
+ step_history: List[Tuple[int, str, str, str]] = field(default_factory=list)
285
+
286
+ def get_filesystem_state() -> Dict[str, str]:
287
+ files = {}
288
+ for f in os.listdir("."):
289
+ if os.path.isfile(f):
290
+ with open(f, 'rb') as fh:
291
+ content = fh.read()
292
+ files[f] = hashlib.md5(content).hexdigest()[:8]
293
+ return files
294
+
295
+ def summarize_step(thought, action, outcome, fs_before, fs_after,
296
+ file_provenance, step_num, _model, _provider, _npc):
297
+ """Advisor compresses a major step and suggests next action."""
298
+ current_files = {}
299
+ for f in os.listdir("."):
300
+ if os.path.isfile(f):
301
+ with open(f, 'rb') as fh:
302
+ content = fh.read()
303
+ current_files[f] = {'size': len(content), 'checksum': hashlib.md5(content).hexdigest()[:8]}
304
+
305
+ for f in fs_after:
306
+ if f not in file_provenance:
307
+ file_provenance[f] = FileProvenance(filename=f)
308
+ if f not in fs_before:
309
+ change = f"Created with {current_files.get(f, {}).get('size', '?')} bytes"
310
+ file_provenance[f].step_history.append((step_num, "CREATED", fs_after[f], change))
311
+ elif fs_before.get(f) != fs_after[f]:
312
+ change = f"Modified to {current_files.get(f, {}).get('size', '?')} bytes"
313
+ file_provenance[f].step_history.append((step_num, "MODIFIED", fs_after[f], change))
314
+
315
+ provenance_summary = []
316
+ for filename, prov in file_provenance.items():
317
+ history = "; ".join([f"Step {s}: {a} ({c}) - {ch}" for s, a, c, ch in prov.step_history])
318
+ provenance_summary.append(f"{filename}: {history}")
319
+
320
+ prompt = f"""AGENT'S REASONING: {str(thought)[:1500]}
321
+
322
+ AGENT'S ACTION: {str(action)[:1000]}
323
+ AGENT'S CLAIMED OUTCOME: {str(outcome)[:1000]}
324
+
325
+ COMPLETE FILE PROVENANCE:
326
+ {chr(10).join(provenance_summary)}
327
+
328
+ CURRENT FILESYSTEM:
329
+ Files: {list(current_files.keys())}
330
+ Details: {current_files}
331
+
332
+ Explain plainly what happened and whether the actions produced any measurable effects.
333
+ If the agent thinks then it is likely time to direct it to carry out a specific action.
334
+
335
+ Return JSON with "summary" and "next_step" keys."""
336
+
337
+ try:
338
+ response = get_llm_response(prompt, model=_model, provider=_provider, npc=_npc, format='json')
339
+ summary_data = response.get('response')
340
+ if isinstance(summary_data, str):
341
+ summary_data = json.loads(summary_data)
342
+ if not isinstance(summary_data, dict):
343
+ summary_data = {"summary": str(summary_data), "next_step": "Continue research."}
344
+ except:
345
+ summary_data = {"summary": "Step completed.", "next_step": "Continue research."}
346
+ return summary_data
347
+
348
+ # ========== Persona Generation (matching original) ==========
349
+ def save_persona_to_csv(persona_data):
350
+ csv_dir = os.path.expanduser("~/.npcsh/npc_team")
351
+ os.makedirs(csv_dir, exist_ok=True)
352
+ csv_path = os.path.join(csv_dir, "alicanto_personas.csv")
353
+ file_exists = os.path.exists(csv_path)
354
+ with open(csv_path, 'a', newline='') as csvfile:
355
+ fieldnames = ['name', 'birth_year', 'location', 'leader', 'interests',
356
+ 'worldview', 'approach', 'persona_text', 'created_at']
357
+ writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
358
+ if not file_exists:
359
+ writer.writeheader()
360
+ row = dict(persona_data)
361
+ if isinstance(row.get('interests'), list):
362
+ row['interests'] = json.dumps(row['interests'])
363
+ row['created_at'] = datetime.now().isoformat()
364
+ writer.writerow(row)
365
+
366
+ def generate_one_persona(birth_year, _model, _provider, _npc):
367
+ """Generate a single persona for a given birth year (original algorithm)."""
368
+ teen_year = birth_year + 16
369
+ json_template = (
370
+ '{\n'
371
+ ' "name": "culturally appropriate full name for someone born in ' + str(birth_year) + '",\n'
372
+ ' "location": "specific city/region where they were born in ' + str(birth_year) + '",\n'
373
+ ' "leader": "who ruled their region when they were 16 years old in ' + str(teen_year) + '",\n'
374
+ ' "interests": ["3-5 specific interests/obsessions they had as a teenager in ' + str(teen_year) + '"],\n'
375
+ ' "worldview": "one sentence describing their fundamental perspective shaped by growing up in that era",\n'
376
+ ' "approach": "how their historical background influences their way of thinking"\n'
377
+ '}'
378
+ )
379
+ prompt = (
380
+ f"Generate a unique persona for someone born in {birth_year}. Return JSON:\n"
381
+ f"{json_template}\n\n"
382
+ f"Make this person feel real and historically grounded. Consider: technological context, "
383
+ f"cultural movements, economic conditions, wars, discoveries happening in {teen_year}."
384
+ )
385
+ response = get_llm_response(prompt, model=_model, provider=_provider, npc=_npc, format='json')
386
+ new_persona = response.get('response')
387
+ if isinstance(new_persona, str):
388
+ raw = new_persona
389
+ start = raw.find('{')
390
+ end = raw.rfind('}') + 1
391
+ if start >= 0 and end > start:
392
+ raw = raw[start:end]
393
+ new_persona = json.loads(raw)
394
+
395
+ interests = new_persona.get('interests', [])
396
+ if isinstance(interests, list):
397
+ interests_str = ', '.join(interests)
398
+ else:
399
+ interests_str = str(interests)
400
+
401
+ persona_text = (
402
+ f"You are {new_persona.get('name')}, born {birth_year} in {new_persona.get('location')}, "
403
+ f"came of age under {new_persona.get('leader')}. "
404
+ f"Your interests were: {interests_str}. "
405
+ f"{new_persona.get('worldview')} {new_persona.get('approach')}"
406
+ )
407
+
408
+ persona_data = {
409
+ 'name': new_persona.get('name', f'Agent'),
410
+ 'birth_year': birth_year,
411
+ 'location': new_persona.get('location', 'Unknown'),
412
+ 'leader': new_persona.get('leader', 'Unknown'),
413
+ 'interests': new_persona.get('interests', []),
414
+ 'worldview': new_persona.get('worldview', ''),
415
+ 'approach': new_persona.get('approach', ''),
416
+ 'persona_text': persona_text,
417
+ }
418
+ try:
419
+ save_persona_to_csv(persona_data)
420
+ except:
421
+ pass
422
+ return persona_data
423
+
424
+ # ========== Sub-Agent Trace (matching original) ==========
425
+ def run_sub_agent_trace(hypothesis, persona_data, user_query, _model, _provider, _max_steps, ui_state):
426
+ """Run one sub-agent trace matching original algorithm."""
427
+ agent_name = persona_data.get('name', 'Agent')
428
+ agent_persona = persona_data.get('persona_text', '')
429
+
430
+ # wander wrapper for when agent is stuck
431
+ def wander_wrapper(problem_description: str) -> str:
432
+ """Get creative ideas when stuck using the wander exploration mode."""
433
+ if not WANDER_AVAILABLE:
434
+ return "Wander not available. Try a different approach."
435
+ try:
436
+ _, _, raw_brainstorm, _, _ = perform_single_wandering(
437
+ problem=problem_description, npc=agent, model=_model, provider=_provider
438
+ )
439
+ return str(raw_brainstorm)
440
+ except Exception as e:
441
+ return f"Wander failed: {e}"
442
+
443
+ tools = [run_python, create_file, append_to_file, replace_in_file, read_file,
444
+ list_files, _web_search_tool, search_papers, shell_command, wander_wrapper]
445
+
446
+ agent = NPC(
447
+ name=agent_name.replace(' ', '_').lower(),
448
+ model=_model,
449
+ provider=_provider,
450
+ primary_directive=_alicanto_directive + "\n\n" + agent_persona,
451
+ tools=tools
452
+ )
453
+
454
+ trace = SubAgentTrace(hypothesis=hypothesis, agent_name=agent_name, agent_persona=agent_persona)
455
+ summarized_history = []
456
+ file_provenance = {}
457
+ created_files = set()
458
+ summary = {}
459
+ major_step = 0
460
+ stall_count = 0 # consecutive steps with no filesystem change
461
+
462
+ while major_step < _max_steps:
463
+ # Check for skip/quit
464
+ if ui_state.get('skip'):
465
+ ui_state['skip'] = False
466
+ ui_state['log'].append(f"\033[33m Skipped by user\033[0m")
467
+ break
468
+ while ui_state.get('paused'):
469
+ time.sleep(0.2)
470
+ if ui_state.get('skip'):
471
+ break
472
+
473
+ fs_before = get_filesystem_state()
474
+
475
+ provenance_summary = []
476
+ for filename, prov in file_provenance.items():
477
+ history = "; ".join([f"Step {s}: {a} ({c}) - {ch}" for s, a, c, ch in prov.step_history])
478
+ provenance_summary.append(f"{filename}: {history}")
479
+
480
+ history_str = "\n".join(summarized_history)
481
+ next_step_text = f"This is the next step suggested by your advisor. : BEGIN NEXT_STEP: {summary.get('next_step')} END NEXT STEP" if summary else ""
482
+
483
+ initial_prompt = f"""Hypothesis: '{hypothesis}'
484
+ Query: '{user_query}'
485
+ Files: {list(fs_before.keys())}
486
+ {history_str}
487
+ {next_step_text}"""
488
+
489
+ ui_state['log'].append(f"\033[90m Major step {major_step + 1}\033[0m")
490
+
491
+ agent_messages = []
492
+ all_thoughts = []
493
+ all_actions = []
494
+ all_outcomes = []
495
+
496
+ for micro_step in range(5):
497
+ if ui_state.get('skip'):
498
+ break
499
+
500
+ if micro_step == 0:
501
+ current_prompt = initial_prompt
502
+ else:
503
+ current_prompt = "Continue your work. What's your next action?"
504
+
505
+ try:
506
+ response = agent.get_llm_response(
507
+ current_prompt,
508
+ messages=agent_messages,
509
+ auto_process_tool_calls=True
510
+ )
511
+ except Exception as e:
512
+ ui_state['log'].append(f" \033[31mLLM error: {str(e)[:80]}\033[0m")
513
+ break
514
+
515
+ agent_messages = response.get('messages', [])
516
+ thought = response.get('response')
517
+ if thought is None:
518
+ thought = ''
519
+ else:
520
+ all_thoughts.append(thought)
521
+ preview = thought.replace('\n', ' ')[:120]
522
+ ui_state['log'].append(f" {preview}")
523
+
524
+ if thought and "RESEARCH_COMPLETE" in thought.upper():
525
+ ui_state['log'].append(f" \033[32mRESEARCH_COMPLETE\033[0m")
526
+ break
527
+
528
+ if response.get('tool_results'):
529
+ tool_results = response['tool_results']
530
+ tool_names = []
531
+ outcomes = []
532
+ for res in tool_results:
533
+ tname = res.get('tool_name', '?')
534
+ tool_names.append(tname)
535
+ args = res.get('arguments', {})
536
+ if tname in ('create_file', 'append_to_file', 'replace_in_file'):
537
+ fname = args.get('filename')
538
+ if fname:
539
+ created_files.add(fname)
540
+ trace.was_successful = True
541
+ outcomes.append(str(res.get('result', ''))[:200])
542
+
543
+ ui_state['log'].append(f" \033[36mTools: {', '.join(tool_names)}\033[0m")
544
+ all_actions.append(", ".join([f"{r.get('tool_name')}({r.get('arguments', {})})" for r in tool_results]))
545
+ all_outcomes.extend(outcomes)
546
+ elif micro_step > 0 and not response.get('tool_calls'):
547
+ break
548
+
549
+ fs_after = get_filesystem_state()
550
+ new_files = set(fs_after.keys()) - set(fs_before.keys())
551
+ changed_files = {f for f in fs_after if fs_before.get(f) != fs_after.get(f)}
552
+ if new_files:
553
+ ui_state['log'].append(f" \033[32mNew files: {list(new_files)}\033[0m")
554
+ stall_count = 0
555
+ elif changed_files:
556
+ stall_count = 0
557
+ else:
558
+ stall_count += 1
559
+ if stall_count >= 3:
560
+ ui_state['log'].append(f" \033[33mStalled for {stall_count} steps, forcing wrap-up\033[0m")
561
+
562
+ combined_thought = " ".join(all_thoughts)
563
+ combined_action = " | ".join(filter(None, all_actions))
564
+ combined_outcome = " | ".join(filter(None, all_outcomes))
565
+
566
+ ui_state['log'].append(f" \033[90mCompressing step...\033[0m")
567
+ summary = summarize_step(
568
+ combined_thought, combined_action, combined_outcome,
569
+ fs_before, fs_after, file_provenance,
570
+ major_step + 1, _model, _provider, agent
571
+ )
572
+
573
+ summary_text = summary.get('summary', 'Step completed.')
574
+ next_text = summary.get('next_step', '')
575
+ ui_state['log'].append(f" \033[32mSummary: {str(summary_text)[:120]}\033[0m")
576
+ if next_text:
577
+ ui_state['log'].append(f" \033[33mNext: {str(next_text)[:100]}\033[0m")
578
+
579
+ summarized_history.append(f"Step {major_step + 1}: {summary_text}")
580
+ trace.steps.append(ResearchStep(
581
+ step=major_step + 1,
582
+ thought=combined_thought,
583
+ action=combined_action,
584
+ outcome=combined_outcome
585
+ ))
586
+
587
+ if combined_thought and "RESEARCH_COMPLETE" in combined_thought.upper():
588
+ break
589
+
590
+ major_step += 1
591
+
592
+ for filename in created_files:
593
+ if os.path.exists(filename):
594
+ try:
595
+ trace.final_files[filename] = read_file(filename)
596
+ except:
597
+ pass
598
+
599
+ return trace
600
+
601
+ # ========== Paper Writing (matching original) ==========
602
+ def write_paper(all_traces, user_query, _model, _provider, coordinator, ui_state):
603
+ """Iterative LaTeX paper building from compressed traces."""
604
+ ui_state['log'].append(f"\n\033[1;36m--- Writing Paper ---\033[0m")
605
+
606
+ # Compress traces for synthesis
607
+ compressed_summaries = []
608
+ for trace in all_traces:
609
+ steps_summary = []
610
+ for step in trace.steps[-3:]:
611
+ thought_short = (step.thought[:100] + "...") if step.thought and len(step.thought) > 100 else (step.thought or "No thought")
612
+ action_short = (step.action[:100] + "...") if step.action and len(step.action) > 100 else (step.action or "No action")
613
+ steps_summary.append(f"Step {step.step}: {thought_short} | {action_short}")
614
+ compressed_summaries.append({
615
+ "agent": trace.agent_name,
616
+ "hypothesis": trace.hypothesis,
617
+ "success": trace.was_successful,
618
+ "key_steps": steps_summary,
619
+ "files_created": list(trace.final_files.keys()),
620
+ })
621
+ compressed_research = json.dumps(compressed_summaries, indent=2)
622
+
623
+ author_list = [trace.agent_name for trace in all_traces]
624
+ author_string = ", ".join(author_list)
625
+
626
+ pct = chr(37) # percent sign - avoid bare % in YAML
627
+ todo = lambda s: f"{pct} TODO: {s}"
628
+ initial_latex = (
629
+ "\\documentclass{article}\n"
630
+ "\\title{" + todo("TITLE") + "}\n"
631
+ "\\author{" + author_string + "}\n"
632
+ "\\date{\\today}\n"
633
+ "\\begin{document}\n"
634
+ "\\maketitle\n\n"
635
+ "\\begin{abstract}\n"
636
+ + todo("ABSTRACT") + "\n"
637
+ "\\end{abstract}\n\n"
638
+ "\\section{Introduction}\n"
639
+ + todo("INTRODUCTION") + "\n\n"
640
+ "\\section{Methods}\n"
641
+ + todo("METHODS") + "\n\n"
642
+ "\\section{Results}\n"
643
+ + todo("RESULTS") + "\n\n"
644
+ "\\section{Discussion}\n"
645
+ + todo("DISCUSSION") + "\n\n"
646
+ "\\end{document}"
647
+ )
648
+
649
+ create_file("paper.tex", initial_latex)
650
+ ui_state['log'].append(f" Created paper.tex scaffold")
651
+
652
+ todo_sections = ["TITLE", "ABSTRACT", "INTRODUCTION", "METHODS", "RESULTS", "DISCUSSION"]
653
+
654
+ for section_round in range(len(todo_sections)):
655
+ current_paper = read_file("paper.tex")
656
+ sections_status = {s: "EMPTY" if (chr(37) + " TODO: " + s) in current_paper else "COMPLETE" for s in todo_sections}
657
+
658
+ next_section = None
659
+ for s in todo_sections:
660
+ if sections_status[s] == "EMPTY":
661
+ next_section = s
662
+ break
663
+ if not next_section:
664
+ ui_state['log'].append(f" \033[32mAll sections complete\033[0m")
665
+ break
666
+
667
+ ui_state['log'].append(f" Writing section: {next_section}")
668
+
669
+ coord_messages = []
670
+ section_prompt = f"""You are writing a research paper about: "{user_query}"
671
+
672
+ Research data from sub-agents: {compressed_research[:3000]}
673
+
674
+ Current paper content:
675
+ {current_paper}
676
+
677
+ Your task: Complete the {next_section} section by replacing the "{chr(37)} TODO: {next_section}" marker with actual content.
678
+
679
+ Use replace_in_file to update the paper. Use _web_search_tool if you need more information.
680
+
681
+ Focus ONLY on the {next_section} section. Write 2-4 paragraphs of substantial academic content.
682
+
683
+ Available tools: replace_in_file, read_file, _web_search_tool, search_papers"""
684
+
685
+ for micro in range(5):
686
+ if micro == 0:
687
+ cprompt = section_prompt
688
+ else:
689
+ cprompt = f"Continue working on the {next_section} section. What's your next action?"
690
+ try:
691
+ resp = coordinator.get_llm_response(
692
+ cprompt, messages=coord_messages, auto_process_tool_calls=True
693
+ )
694
+ coord_messages = resp.get('messages', [])
695
+ if resp.get('tool_results'):
696
+ for tr in resp['tool_results']:
697
+ ui_state['log'].append(f" \033[36m{tr.get('tool_name', '?')}\033[0m")
698
+ except:
699
+ break
700
+
701
+ final_paper = read_file("paper.tex")
702
+ return final_paper
703
+
704
+ # ========== Sub-Agent Review Cycle ==========
705
+ def do_sub_agent_reviews(all_traces, paper_content, user_query, _model, _provider, cycle_num, ui_state):
706
+ """Each sub-agent reviews the paper and provides suggestions."""
707
+ ui_state['log'].append(f"\n\033[1;35m--- Cycle {cycle_num}: Sub-Agent Reviews ---\033[0m")
708
+ suggestions = []
709
+
710
+ for i, trace in enumerate(all_traces):
711
+ if ui_state.get('skip'):
712
+ ui_state['skip'] = False
713
+ break
714
+ while ui_state.get('paused'):
715
+ time.sleep(0.2)
716
+ if ui_state.get('skip'):
717
+ break
718
+
719
+ agent_name = trace.agent_name
720
+ ui_state['log'].append(f"\n\033[36m Reviewer: {agent_name}\033[0m")
721
+
722
+ # Build context of what this agent found
723
+ agent_findings = []
724
+ for step in trace.steps:
725
+ if step.thought:
726
+ agent_findings.append(f"Step {step.step}: {step.thought[:300]}")
727
+ findings_text = "\n".join(agent_findings[-5:])
728
+ files_text = ", ".join(trace.final_files.keys()) if trace.final_files else "(none)"
729
+
730
+ review_prompt = f"""You are {agent_name}, a research sub-agent. You previously investigated the hypothesis:
731
+ "{trace.hypothesis}"
732
+
733
+ Your key findings were:
734
+ {findings_text}
735
+
736
+ Files you created: {files_text}
737
+
738
+ Now review the following research paper written about the query: "{user_query}"
739
+
740
+ === PAPER ===
741
+ {paper_content[:4000]}
742
+ === END PAPER ===
743
+
744
+ Based on your expertise and findings, provide a critical review with specific suggestions:
745
+ 1. Are your findings accurately represented?
746
+ 2. What important findings or nuances are missing?
747
+ 3. What claims need stronger evidence or qualification?
748
+ 4. What additional analysis or experiments should be done?
749
+ 5. Specific text improvements (cite sections by name).
750
+
751
+ Be concrete and specific. Reference sections (Introduction, Methods, Results, Discussion) directly."""
752
+
753
+ try:
754
+ resp = get_llm_response(review_prompt, model=_model, provider=_provider, npc=None)
755
+ review_text = resp.get('response', '')
756
+ if review_text:
757
+ suggestions.append({
758
+ 'agent': agent_name,
759
+ 'hypothesis': trace.hypothesis,
760
+ 'review': review_text,
761
+ })
762
+ preview = review_text.replace('\n', ' ')[:120]
763
+ ui_state['log'].append(f" {preview}")
764
+ else:
765
+ ui_state['log'].append(f" \033[90m(empty review)\033[0m")
766
+ except Exception as e:
767
+ ui_state['log'].append(f" \033[31mReview error: {str(e)[:80]}\033[0m")
768
+
769
+ return suggestions
770
+
771
+ def revise_paper(paper_content, suggestions, user_query, coordinator, cycle_num, ui_state):
772
+ """Coordinator revises the paper based on sub-agent suggestions."""
773
+ ui_state['log'].append(f"\n\033[1;36m--- Cycle {cycle_num}: Revising Paper ---\033[0m")
774
+
775
+ # Format all suggestions
776
+ suggestions_text = ""
777
+ for s in suggestions:
778
+ suggestions_text += f"\n--- Review by {s['agent']} (Hypothesis: {s['hypothesis'][:80]}) ---\n"
779
+ suggestions_text += s['review'][:1500] + "\n"
780
+
781
+ # Write current paper to file for coordinator to edit
782
+ paper_path = os.path.abspath("paper.tex")
783
+ with open(paper_path, 'w') as f:
784
+ f.write(paper_content)
785
+
786
+ revision_prompt = f"""You are revising a research paper about: "{user_query}"
787
+
788
+ This is revision cycle {cycle_num}. Your sub-agents have reviewed the paper and provided suggestions.
789
+
790
+ CURRENT PAPER:
791
+ {paper_content[:4000]}
792
+
793
+ SUB-AGENT REVIEWS AND SUGGESTIONS:
794
+ {suggestions_text[:4000]}
795
+
796
+ Your task:
797
+ 1. Read the reviews carefully
798
+ 2. Incorporate valid suggestions using replace_in_file on paper.tex
799
+ 3. Strengthen weak sections
800
+ 4. Add missing findings or nuances
801
+ 5. Improve clarity and academic rigor
802
+ 6. Do NOT remove existing good content - only improve and add
803
+
804
+ Use replace_in_file to make targeted improvements to paper.tex.
805
+ Use read_file to check current state.
806
+
807
+ Available tools: replace_in_file, read_file, append_to_file, _web_search_tool, search_papers"""
808
+
809
+ coord_messages = []
810
+ for micro in range(8):
811
+ if ui_state.get('skip'):
812
+ break
813
+ if micro == 0:
814
+ cprompt = revision_prompt
815
+ else:
816
+ cprompt = "Continue revising the paper. What's your next improvement?"
817
+ try:
818
+ resp = coordinator.get_llm_response(
819
+ cprompt, messages=coord_messages, auto_process_tool_calls=True
820
+ )
821
+ coord_messages = resp.get('messages', [])
822
+ response_text = resp.get('response', '')
823
+ if resp.get('tool_results'):
824
+ for tr in resp['tool_results']:
825
+ ui_state['log'].append(f" \033[36m{tr.get('tool_name', '?')}\033[0m")
826
+ elif micro > 0 and not resp.get('tool_calls'):
827
+ break
828
+ except Exception as e:
829
+ ui_state['log'].append(f" \033[31mRevision error: {str(e)[:80]}\033[0m")
830
+ break
831
+
832
+ revised = read_file("paper.tex")
833
+ ui_state['log'].append(f" \033[32mRevision cycle {cycle_num} complete\033[0m")
834
+ return revised
835
+
836
+ # ========== Trace Saving (matching original) ==========
837
+ def save_traces_csv(all_traces):
838
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
839
+ trace_dir = os.path.join(_work_dir, "alicanto_traces")
840
+ os.makedirs(trace_dir, exist_ok=True)
841
+ filepath = os.path.join(trace_dir, f"trace_{timestamp}.csv")
842
+ rows = []
843
+ for trace in all_traces:
844
+ for step in trace.steps:
845
+ rows.append({
846
+ "hypothesis": trace.hypothesis,
847
+ "agent_name": trace.agent_name,
848
+ "agent_persona": trace.agent_persona,
849
+ "was_successful": trace.was_successful,
850
+ "step": step.step,
851
+ "thought": step.thought[:2000],
852
+ "action": step.action[:1000],
853
+ "outcome": step.outcome[:1000],
854
+ "final_files": json.dumps(list(trace.final_files.keys())),
855
+ })
856
+ if rows:
857
+ with open(filepath, 'w', newline='') as f:
858
+ writer = csv.DictWriter(f, fieldnames=rows[0].keys())
859
+ writer.writeheader()
860
+ writer.writerows(rows)
861
+ return filepath
862
+
863
+ # ========== Global State ==========
864
+ class AlicantoState:
865
+ def __init__(self):
866
+ self.phase = 0 # 0=query, 1=hypotheses, 2=personas, 3=execution, 4=review
867
+ self.auto_mode = False
868
+ self.query = query or ""
869
+ self.hypotheses = [] # list of hypothesis strings
870
+ self.personas = [] # list of persona dicts
871
+ self.traces = [] # list of SubAgentTrace
872
+ self.current_agent = -1
873
+ # UI state
874
+ self.sel = 0
875
+ self.scroll = 0
876
+ self.mode = 'normal' # normal, edit
877
+ self.input_buf = ""
878
+ self.input_target = ""
879
+ self.status = ""
880
+ self.generating = False
881
+ self.error = ""
882
+ # Review state
883
+ self.review_tab = 0 # 0=agents, 1=insights, 2=files, 3=paper
884
+ self.starred = set()
885
+ self.paper = ""
886
+ self.all_insights = []
887
+ self.all_files = [] # [{name, agent, content}]
888
+ # Execution display
889
+ self.exec_log = []
890
+ self.exec_scroll = 0
891
+ self.exec_paused = False
892
+ self.exec_skip = False
893
+ self.current_cycle = 0
894
+ self.total_cycles = num_cycles
895
+ self.end_cycles = False
896
+ # Shared state for sub-agent communication
897
+ self.agent_ui = {'log': [], 'skip': False, 'paused': False}
898
+
899
+ @property
900
+ def phase_name(self):
901
+ return ["Query", "Hypotheses", "Personas", "Execution", "Review"][self.phase]
902
+
903
+ ui = AlicantoState()
904
+
905
+ # ========== Query Entry ==========
906
+ if not ui.query:
907
+ if sys.stdin.isatty():
908
+ print("\033[1;36m ALICANTO - Multi-Agent Research System \033[0m")
909
+ print("\033[90mEnter your research query (or 'q' to quit):\033[0m")
910
+ try:
911
+ ui.query = input("\033[33m> \033[0m").strip()
912
+ except (EOFError, KeyboardInterrupt):
913
+ ui.query = ""
914
+ if not ui.query or ui.query.lower() == 'q':
915
+ os.chdir(_orig_cwd)
916
+ context['output'] = "Alicanto cancelled."
917
+ context['messages'] = messages
918
+ exit()
919
+ else:
920
+ os.chdir(_orig_cwd)
921
+ context['output'] = """Usage: /alicanto <research query>
922
+
923
+ Options:
924
+ --num-npcs N Number of sub-agents (default: 3)
925
+ --max-steps N Max major steps per agent (default: 10)
926
+ --num-cycles N Review/revision cycles (default: 3)
927
+ --model MODEL LLM model
928
+ --provider PROVIDER LLM provider
929
+
930
+ Example: /alicanto What are the latest advances in quantum computing?"""
931
+ context['messages'] = messages
932
+ exit()
933
+
934
+ # ========== Generation Functions ==========
935
+ def do_generate_hypotheses():
936
+ ui.status = "Generating hypotheses..."
937
+ ui.generating = True
938
+ ui.error = ""
939
+ try:
940
+ one_shot = '''
941
+ "example_input": "Investigate the impact of quantum annealing on protein folding.",
942
+ "example_output": {
943
+ "hypotheses": [
944
+ "Implementing a quantum annealer simulation for a small peptide chain will identify lower energy states faster than a classical simulated annealing approach.",
945
+ "The choice of qubit connectivity in the quantum annealer topology significantly impacts the final folded state accuracy for proteins with long-range interactions.",
946
+ "Encoding the protein residue interactions as a QUBO problem is feasible for structures up to 50 amino acids before qubit requirements become prohibitive."
947
+ ]
948
+ }'''
949
+ prompt = f"""Based on the following research topic, generate a list of {num_npcs} distinct, specific, and empirically testable hypotheses.
950
+
951
+ TOPIC: "{ui.query}"
952
+
953
+ Return a JSON object with a single key "hypotheses" which is a list of strings.
954
+
955
+ Here is an example of the expected input and output format:
956
+ {one_shot}
957
+
958
+ Return ONLY the JSON object."""
959
+
960
+ resp = get_llm_response(prompt, model=model, provider=provider, npc=npc, format='json')
961
+ result = resp.get('response')
962
+ if isinstance(result, str):
963
+ raw = result
964
+ start = raw.find('{')
965
+ end = raw.rfind('}') + 1
966
+ if start >= 0 and end > start:
967
+ raw = raw[start:end]
968
+ result = json.loads(raw)
969
+ if isinstance(result, dict):
970
+ ui.hypotheses = result.get('hypotheses', [])
971
+ elif isinstance(result, list):
972
+ ui.hypotheses = result
973
+ else:
974
+ ui.hypotheses = [str(result)]
975
+
976
+ if not ui.hypotheses:
977
+ ui.hypotheses = [f"Investigate: {ui.query}"]
978
+ except Exception as e:
979
+ ui.error = f"Hypothesis error: {e}"
980
+ ui.hypotheses = [f"Investigate: {ui.query}"]
981
+ ui.generating = False
982
+ ui.status = f"{len(ui.hypotheses)} hypotheses generated"
983
+
984
+ def do_generate_personas():
985
+ ui.status = "Generating personas..."
986
+ ui.generating = True
987
+ ui.error = ""
988
+ n = len(ui.hypotheses)
989
+ ui.personas = []
990
+ for i in range(n):
991
+ birth_year = random.randint(-32665, 32665)
992
+ ui.status = f"Generating persona {i+1}/{n} (born {birth_year})..."
993
+ try:
994
+ persona = generate_one_persona(birth_year, model, provider, npc)
995
+ persona['hypothesis_idx'] = i
996
+ ui.personas.append(persona)
997
+ except Exception as e:
998
+ ui.personas.append({
999
+ 'name': f'Agent {i+1}', 'birth_year': birth_year,
1000
+ 'location': 'Unknown', 'leader': 'Unknown',
1001
+ 'interests': [], 'worldview': '', 'approach': '',
1002
+ 'persona_text': f'You are Agent {i+1}, born {birth_year}.',
1003
+ 'hypothesis_idx': i,
1004
+ })
1005
+ ui.error = f"Persona {i+1} error: {e}"
1006
+ ui.generating = False
1007
+ ui.status = f"{len(ui.personas)} personas generated"
1008
+
1009
+ def do_run_all_agents():
1010
+ """Run all sub-agents serially, write paper, then iterate review cycles."""
1011
+ ui.generating = True
1012
+ ui.error = ""
1013
+ ui.traces = []
1014
+ ui.agent_ui = {'log': ui.exec_log, 'skip': False, 'paused': False}
1015
+
1016
+ # Create coordinator NPC for paper writing
1017
+ def wander_wrapper_coord(problem_description: str) -> str:
1018
+ """Get creative ideas when stuck."""
1019
+ if not WANDER_AVAILABLE:
1020
+ return "Wander not available."
1021
+ try:
1022
+ _, _, raw, _, _ = perform_single_wandering(
1023
+ problem=problem_description, npc=coordinator, model=model, provider=provider
1024
+ )
1025
+ return str(raw)
1026
+ except Exception as e:
1027
+ return f"Wander failed: {e}"
1028
+
1029
+ coord_tools = [run_python, create_file, append_to_file, replace_in_file, read_file,
1030
+ list_files, _web_search_tool, search_papers, shell_command, wander_wrapper_coord]
1031
+
1032
+ coordinator = NPC(
1033
+ name="Alicanto",
1034
+ model=model, provider=provider,
1035
+ primary_directive=_alicanto_directive,
1036
+ tools=coord_tools
1037
+ )
1038
+
1039
+ # ===== Cycle 1: Sub-agent experimentation =====
1040
+ ui.current_cycle = 1
1041
+ ui.exec_log.append(f"\n\033[1;33m{'='*50}\033[0m")
1042
+ ui.exec_log.append(f"\033[1;33m CYCLE 1/{num_cycles}: Sub-Agent Experimentation\033[0m")
1043
+ ui.exec_log.append(f"\033[1;33m{'='*50}\033[0m")
1044
+
1045
+ for i in range(len(ui.hypotheses)):
1046
+ hyp = ui.hypotheses[i]
1047
+ persona = ui.personas[i % len(ui.personas)]
1048
+ ui.current_agent = i
1049
+ ui.exec_log.append(f"\n\033[1;36m--- Agent {i+1}/{len(ui.hypotheses)}: {persona['name']} ---\033[0m")
1050
+ ui.exec_log.append(f" \033[90mHypothesis: {hyp[:100]}\033[0m")
1051
+ ui.exec_log.append(f" \033[90mBorn {persona['birth_year']} in {persona.get('location', '?')}\033[0m")
1052
+
1053
+ ui.agent_ui['skip'] = ui.exec_skip
1054
+ ui.agent_ui['paused'] = ui.exec_paused
1055
+
1056
+ trace = run_sub_agent_trace(hyp, persona, ui.query, model, provider, max_steps, ui.agent_ui)
1057
+ ui.traces.append(trace)
1058
+ ui.exec_log.append(f" \033[1;32mCompleted: {'SUCCESS' if trace.was_successful else 'no files'}, {len(trace.steps)} steps\033[0m")
1059
+
1060
+ # Save traces
1061
+ try:
1062
+ trace_path = save_traces_csv(ui.traces)
1063
+ ui.exec_log.append(f"\n\033[90mTraces saved: {trace_path}\033[0m")
1064
+ except Exception as e:
1065
+ ui.exec_log.append(f"\n\033[31mTrace save error: {e}\033[0m")
1066
+
1067
+ # ===== Cycle 1: Coordinator writes initial paper =====
1068
+ ui.exec_log.append(f"\n\033[1;36m--- Coordinator: Writing Initial Paper ---\033[0m")
1069
+ try:
1070
+ ui.paper = write_paper(ui.traces, ui.query, model, provider, coordinator, ui.agent_ui)
1071
+ except Exception as e:
1072
+ ui.paper = f"Paper writing error: {e}"
1073
+ ui.exec_log.append(f" \033[31m{e}\033[0m")
1074
+
1075
+ # ===== Cycles 2..N: Review and revise =====
1076
+ for cycle in range(2, num_cycles + 1):
1077
+ if ui.end_cycles:
1078
+ ui.exec_log.append(f"\n\033[33mCycles ended early by user at cycle {cycle-1}\033[0m")
1079
+ break
1080
+
1081
+ ui.current_cycle = cycle
1082
+ ui.exec_log.append(f"\n\033[1;33m{'='*50}\033[0m")
1083
+ ui.exec_log.append(f"\033[1;33m CYCLE {cycle}/{num_cycles}: Review & Revise\033[0m")
1084
+ ui.exec_log.append(f"\033[1;33m{'='*50}\033[0m")
1085
+
1086
+ # Sub-agents review the paper
1087
+ suggestions = do_sub_agent_reviews(
1088
+ ui.traces, ui.paper, ui.query, model, provider, cycle, ui.agent_ui
1089
+ )
1090
+
1091
+ if not suggestions:
1092
+ ui.exec_log.append(f"\n\033[90mNo suggestions received, skipping revision\033[0m")
1093
+ continue
1094
+
1095
+ if ui.end_cycles:
1096
+ break
1097
+
1098
+ # Coordinator revises based on suggestions
1099
+ ui.paper = revise_paper(
1100
+ ui.paper, suggestions, ui.query, coordinator, cycle, ui.agent_ui
1101
+ )
1102
+
1103
+ ui.exec_log.append(f"\n\033[1;32m{'='*50}\033[0m")
1104
+ ui.exec_log.append(f"\033[1;32m All {ui.current_cycle} cycle(s) complete\033[0m")
1105
+ ui.exec_log.append(f"\033[1;32m{'='*50}\033[0m")
1106
+
1107
+ # Collect insights and files
1108
+ ui.all_insights = []
1109
+ ui.all_files = []
1110
+ for trace in ui.traces:
1111
+ for step in trace.steps:
1112
+ if step.thought:
1113
+ ui.all_insights.append(f"[{trace.agent_name}] Step {step.step}: {step.thought[:300]}")
1114
+ for fname, content in trace.final_files.items():
1115
+ ui.all_files.append({"name": fname, "agent": trace.agent_name, "content": content[:1000]})
1116
+
1117
+ ui.generating = False
1118
+ ui.phase = 4
1119
+ ui.sel = 0
1120
+ ui.scroll = 0
1121
+ ui.status = f"Research complete ({ui.current_cycle} cycles). Review results."
1122
+
1123
+ # ========== TUI Rendering ==========
1124
+ def render_screen():
1125
+ width, height = get_size()
1126
+ out = []
1127
+ out.append("\033[2J\033[H")
1128
+
1129
+ auto_badge = " \033[32m[AUTO]\033[0m" if ui.auto_mode else ""
1130
+ phase_display = f" ALICANTO - Phase {ui.phase+1}: {ui.phase_name} "
1131
+ out.append(f"\033[1;1H\033[7;1m{phase_display.ljust(width)}\033[0m")
1132
+ out.append(f"\033[1;{width-20}H{auto_badge}")
1133
+
1134
+ if ui.phase == 0:
1135
+ render_phase_query(out, width, height)
1136
+ elif ui.phase == 1:
1137
+ render_phase_hypotheses(out, width, height)
1138
+ elif ui.phase == 2:
1139
+ render_phase_personas(out, width, height)
1140
+ elif ui.phase == 3:
1141
+ render_phase_execution(out, width, height)
1142
+ elif ui.phase == 4:
1143
+ render_phase_review(out, width, height)
1144
+
1145
+ status_color = "\033[33m" if ui.generating else "\033[90m"
1146
+ err_text = f" \033[31m{ui.error}\033[0m" if ui.error else ""
1147
+ out.append(f"\033[{height-1};1H\033[K{status_color}{ui.status}\033[0m{err_text}")
1148
+
1149
+ sys.stdout.write(''.join(out))
1150
+ sys.stdout.flush()
1151
+
1152
+ def render_phase_query(out, width, height):
1153
+ banner = [
1154
+ "\033[36m █████╗ ██╗ ██╗ ██████╗ █████╗ ███╗ ██╗████████╗ ██████╗\033[0m",
1155
+ "\033[36m██╔══██╗██║ ██║██╔════╝██╔══██╗████╗ ██║╚══██╔══╝██╔═══██╗\033[0m",
1156
+ "\033[36m███████║██║ ██║██║ ███████║██╔██╗ ██║ ██║ ██║ ██║\033[0m",
1157
+ "\033[36m██╔══██║██║ ██║██║ ██╔══██║██║╚██╗██║ ██║ ██║ ██║\033[0m",
1158
+ "\033[36m██║ ██║███████╗██║╚██████╗██║ ██║██║ ╚████║ ██║ ╚██████╔╝\033[0m",
1159
+ "\033[36m╚═╝ ╚═╝╚══════╝╚═╝ ╚═════╝╚═╝ ╚═╝╚═╝ ╚═══╝ ╚═╝ ╚═════╝\033[0m",
1160
+ ]
1161
+ for i, line in enumerate(banner):
1162
+ out.append(f"\033[{3+i};3H{line}")
1163
+
1164
+ y = 3 + len(banner) + 2
1165
+ out.append(f"\033[{y};3H\033[1mQuery:\033[0m {ui.query}")
1166
+ out.append(f"\033[{y+1};3H\033[90mAgents: {num_npcs} | Max steps: {max_steps} | Cycles: {num_cycles} | Model: {model or 'default'} | Provider: {provider or 'default'}\033[0m")
1167
+ out.append(f"\033[{height};1H\033[K\033[7m Enter:Continue A:Auto-mode q:Quit \033[0m".ljust(width))
1168
+
1169
+ def render_phase_hypotheses(out, width, height):
1170
+ list_h = height - 6
1171
+ n = len(ui.hypotheses)
1172
+
1173
+ if ui.mode == 'edit':
1174
+ out.append(f"\033[3;1H\033[33mEditing hypothesis:\033[0m")
1175
+ out.append(f"\033[4;1H\033[7m {ui.input_buf} \033[0m")
1176
+ out.append(f"\033[{height};1H\033[K\033[7m Enter:Save Esc:Cancel \033[0m".ljust(width))
1177
+ return
1178
+
1179
+ out.append(f"\033[3;1H\033[1m Hypotheses ({n}) \033[90m{'─' * (width - 20)}\033[0m")
1180
+
1181
+ if ui.sel < ui.scroll:
1182
+ ui.scroll = ui.sel
1183
+ elif ui.sel >= ui.scroll + (list_h // 2):
1184
+ ui.scroll = ui.sel - (list_h // 2) + 1
1185
+
1186
+ y = 4
1187
+ for i in range(len(ui.hypotheses)):
1188
+ if i < ui.scroll:
1189
+ continue
1190
+ if y >= height - 3:
1191
+ break
1192
+ h = ui.hypotheses[i]
1193
+ selected = (i == ui.sel)
1194
+ prefix = "\033[7m>" if selected else " "
1195
+ out.append(f"\033[{y};1H\033[K{prefix} \033[1mH{i+1}\033[0m: {str(h)[:width-10]}")
1196
+ if selected:
1197
+ out.append("\033[0m")
1198
+ y += 1
1199
+ if selected:
1200
+ for dl in wrap_text(h, width - 6)[1:4]:
1201
+ if y >= height - 3:
1202
+ break
1203
+ out.append(f"\033[{y};5H\033[K\033[90m{dl}\033[0m")
1204
+ y += 1
1205
+ y += 1
1206
+
1207
+ while y < height - 2:
1208
+ out.append(f"\033[{y};1H\033[K")
1209
+ y += 1
1210
+
1211
+ gen = " (generating...)" if ui.generating else ""
1212
+ out.append(f"\033[{height};1H\033[K\033[7m j/k:Nav e:Edit d:Delete +:Add r:Regen Enter:Approve A:Auto q:Quit{gen} \033[0m".ljust(width))
1213
+
1214
+ def render_phase_personas(out, width, height):
1215
+ list_h = height - 6
1216
+ n = len(ui.personas)
1217
+
1218
+ if ui.mode == 'edit':
1219
+ out.append(f"\033[3;1H\033[33mEditing {ui.input_target}:\033[0m")
1220
+ out.append(f"\033[4;1H\033[7m {ui.input_buf} \033[0m")
1221
+ out.append(f"\033[{height};1H\033[K\033[7m Enter:Save Esc:Cancel \033[0m".ljust(width))
1222
+ return
1223
+
1224
+ out.append(f"\033[3;1H\033[1m Personas ({n}) \033[90m{'─' * (width - 20)}\033[0m")
1225
+
1226
+ if ui.sel < ui.scroll:
1227
+ ui.scroll = ui.sel
1228
+ elif ui.sel >= ui.scroll + (list_h // 5):
1229
+ ui.scroll = ui.sel - (list_h // 5) + 1
1230
+
1231
+ y = 4
1232
+ for i in range(len(ui.personas)):
1233
+ if i < ui.scroll:
1234
+ continue
1235
+ if y >= height - 3:
1236
+ break
1237
+ p = ui.personas[i]
1238
+ selected = (i == ui.sel)
1239
+ prefix = "\033[7m>" if selected else " "
1240
+ hyp_idx = p.get('hypothesis_idx', 0)
1241
+ hyp_text = ui.hypotheses[hyp_idx][:60] if hyp_idx < len(ui.hypotheses) else "?"
1242
+
1243
+ out.append(f"\033[{y};1H\033[K{prefix} \033[1m{p['name']}\033[0m \033[90m(b.{p['birth_year']}, {p.get('location', '?')})\033[0m")
1244
+ y += 1
1245
+ if selected and y < height - 3:
1246
+ out.append(f"\033[{y};5H\033[K\033[90mLeader: {p.get('leader', '?')}\033[0m")
1247
+ y += 1
1248
+ interests = p.get('interests', [])
1249
+ if isinstance(interests, list):
1250
+ interests = ', '.join(interests)
1251
+ if y < height - 3:
1252
+ out.append(f"\033[{y};5H\033[K\033[90mInterests: {str(interests)[:width-20]}\033[0m")
1253
+ y += 1
1254
+ if y < height - 3:
1255
+ out.append(f"\033[{y};5H\033[K\033[90mWorldview: {p.get('worldview', '')[:width-20]}\033[0m")
1256
+ y += 1
1257
+ if y < height - 3:
1258
+ out.append(f"\033[{y};5H\033[K\033[36mHypothesis: {hyp_text}\033[0m")
1259
+ y += 1
1260
+ y += 1
1261
+
1262
+ while y < height - 2:
1263
+ out.append(f"\033[{y};1H\033[K")
1264
+ y += 1
1265
+
1266
+ gen = " (generating...)" if ui.generating else ""
1267
+ out.append(f"\033[{height};1H\033[K\033[7m j/k:Nav e:Edit r:Regen s:Swap hyp Enter:Launch A:Auto q:Quit{gen} \033[0m".ljust(width))
1268
+
1269
+ def render_phase_execution(out, width, height):
1270
+ left_w = max(30, width // 3)
1271
+ right_w = width - left_w - 1
1272
+ panel_h = height - 4
1273
+
1274
+ out.append(f"\033[3;1H\033[36;1m Agent Info \033[90m{'─' * (left_w - 13)}\033[0m")
1275
+
1276
+ if 0 <= ui.current_agent < len(ui.personas):
1277
+ p = ui.personas[ui.current_agent]
1278
+ hi = p.get('hypothesis_idx', 0)
1279
+ hyp = ui.hypotheses[hi] if hi < len(ui.hypotheses) else "?"
1280
+ y = 4
1281
+ out.append(f"\033[{y};2H\033[1m{p['name']}\033[0m"); y += 1
1282
+ out.append(f"\033[{y};2H\033[90mb.{p['birth_year']}, {p.get('location', '?')[:left_w-10]}\033[0m"); y += 1
1283
+ out.append(f"\033[{y};2H\033[90mLeader: {p.get('leader', '?')[:left_w-12]}\033[0m"); y += 1
1284
+ interests = p.get('interests', [])
1285
+ if isinstance(interests, list):
1286
+ interests = ', '.join(interests)
1287
+ out.append(f"\033[{y};2H\033[90m{str(interests)[:left_w-4]}\033[0m"); y += 1
1288
+ y += 1
1289
+ out.append(f"\033[{y};2H\033[36mHypothesis:\033[0m"); y += 1
1290
+ for hl in wrap_text(str(hyp), left_w - 4)[:3]:
1291
+ out.append(f"\033[{y};3H{hl[:left_w-4]}"); y += 1
1292
+ y += 1
1293
+ done = sum(1 for t in ui.traces if t)
1294
+ out.append(f"\033[{y};2H\033[90mAgent {ui.current_agent+1}/{len(ui.hypotheses)}\033[0m"); y += 1
1295
+ out.append(f"\033[{y};2H\033[90mDone: {done}\033[0m")
1296
+ else:
1297
+ out.append(f"\033[4;2H\033[90mWaiting...\033[0m")
1298
+
1299
+ out.append(f"\033[3;{left_w+1}H\033[33;1m Live Output \033[90m{'─' * (right_w - 14)}\033[0m")
1300
+
1301
+ log_h = panel_h - 1
1302
+ total = len(ui.exec_log)
1303
+ visible_start = max(0, total - log_h - ui.exec_scroll)
1304
+
1305
+ for i in range(log_h):
1306
+ idx = visible_start + i
1307
+ row = 4 + i
1308
+ out.append(f"\033[{row};{left_w+1}H\033[K")
1309
+ if 0 <= idx < total:
1310
+ out.append(f"\033[{row};{left_w+2}H{ui.exec_log[idx][:right_w-2]}")
1311
+
1312
+ pause_text = " \033[31m[PAUSED]\033[0m" if ui.exec_paused else ""
1313
+ done_count = len(ui.traces)
1314
+ cycle_text = f"[Cycle {ui.current_cycle}/{ui.total_cycles}] " if ui.current_cycle > 0 else ""
1315
+ progress = f"{cycle_text}[Agent {ui.current_agent+1}/{len(ui.hypotheses)}] [Done: {done_count}]"
1316
+ out.append(f"\033[{height-1};1H\033[K\033[90m{progress}\033[0m")
1317
+ out.append(f"\033[{height};1H\033[K\033[7m s:Skip p:Pause x:End cycles j/k:Scroll q:Quit{pause_text} \033[0m".ljust(width))
1318
+
1319
+ def render_phase_review(out, width, height):
1320
+ tabs = ['Agents', 'Insights', 'Files', 'Paper']
1321
+ tab_bar = " "
1322
+ for i, tab in enumerate(tabs):
1323
+ if i == ui.review_tab:
1324
+ tab_bar += f'\033[43;30;1m {tab} \033[0m '
1325
+ else:
1326
+ tab_bar += f'\033[90m {tab} \033[0m '
1327
+ out.append(f"\033[3;1H{tab_bar}")
1328
+ out.append(f"\033[4;1H\033[90m{'─' * width}\033[0m")
1329
+
1330
+ content_h = height - 7
1331
+ content_lines = []
1332
+
1333
+ if ui.review_tab == 0:
1334
+ for i, trace in enumerate(ui.traces):
1335
+ sel = (i == ui.sel)
1336
+ prefix = "\033[7m>" if sel else " "
1337
+ status = "\033[32mSUCCESS\033[0m" if trace.was_successful else "\033[90mno files\033[0m"
1338
+ content_lines.append(f"{prefix} \033[1m{trace.agent_name}\033[0m [{status}] Steps: {len(trace.steps)} Files: {len(trace.final_files)}")
1339
+ if sel:
1340
+ content_lines.append(f" \033[36mHypothesis: {trace.hypothesis[:width-20]}\033[0m")
1341
+ content_lines.append(f" \033[90mPersona: {trace.agent_persona[:width-14]}\033[0m")
1342
+ if trace.final_files:
1343
+ content_lines.append(f" \033[90mFiles: {', '.join(trace.final_files.keys())}\033[0m")
1344
+ content_lines.append("")
1345
+
1346
+ elif ui.review_tab == 1:
1347
+ for i, insight in enumerate(ui.all_insights):
1348
+ sel = (i == ui.sel)
1349
+ starred = "★ " if i in ui.starred else " "
1350
+ prefix = "\033[7m>" if sel else " "
1351
+ star_color = "\033[33m" if i in ui.starred else ""
1352
+ content_lines.append(f"{prefix}{star_color}{starred}{insight[:width-8]}\033[0m")
1353
+
1354
+ elif ui.review_tab == 2:
1355
+ if not ui.all_files:
1356
+ content_lines.append(" No files created during research.")
1357
+ for i, finfo in enumerate(ui.all_files):
1358
+ sel = (i == ui.sel)
1359
+ prefix = "\033[7m>" if sel else " "
1360
+ content_lines.append(f"{prefix} \033[1m{finfo['name']}\033[0m \033[90m(by {finfo['agent']})\033[0m")
1361
+ if sel and finfo.get('content'):
1362
+ for pl in wrap_text(finfo['content'], width - 8)[:6]:
1363
+ content_lines.append(f" \033[90m{pl}\033[0m")
1364
+ content_lines.append("")
1365
+
1366
+ elif ui.review_tab == 3:
1367
+ if not ui.paper:
1368
+ content_lines.append(" No paper generated yet.")
1369
+ else:
1370
+ content_lines = wrap_text(ui.paper, width - 4)
1371
+
1372
+ if ui.scroll > max(0, len(content_lines) - 1):
1373
+ ui.scroll = max(0, len(content_lines) - 1)
1374
+
1375
+ for i in range(content_h):
1376
+ idx = ui.scroll + i
1377
+ row = 5 + i
1378
+ out.append(f"\033[{row};1H\033[K")
1379
+ if idx < len(content_lines):
1380
+ out.append(f"\033[{row};2H{content_lines[idx][:width-2]}")
1381
+
1382
+ if len(content_lines) > content_h and content_h > 0:
1383
+ pct = ui.scroll / max(1, len(content_lines) - content_h)
1384
+ pos = int(pct * (content_h - 1))
1385
+ out.append(f"\033[{5+pos};{width}H\033[33m▐\033[0m")
1386
+
1387
+ gen = " (generating...)" if ui.generating else ""
1388
+ out.append(f"\033[{height};1H\033[K\033[7m Tab/h/l:Tabs j/k:Nav s:Star Enter:View q:Quit{gen} \033[0m".ljust(width))
1389
+
1390
+ # ========== Input Handling ==========
1391
+ def handle_input(c, fd):
1392
+ if ui.mode == 'edit':
1393
+ return handle_edit_input(c, fd)
1394
+
1395
+ if c == '\x1b':
1396
+ if _sel.select([fd], [], [], 0.05)[0]:
1397
+ c2 = os.read(fd, 1).decode('latin-1')
1398
+ if c2 == '[':
1399
+ c3 = os.read(fd, 1).decode('latin-1')
1400
+ if c3 == 'A': nav_up()
1401
+ elif c3 == 'B': nav_down()
1402
+ elif c3 == 'C': nav_right()
1403
+ elif c3 == 'D': nav_left()
1404
+ else:
1405
+ return False
1406
+ return True
1407
+
1408
+ if c == 'q' or c == '\x03':
1409
+ return False
1410
+
1411
+ if c == 'A' and ui.phase < 3:
1412
+ ui.auto_mode = True
1413
+ ui.status = "Auto mode enabled"
1414
+ advance_phase()
1415
+ return True
1416
+
1417
+ if ui.phase == 0:
1418
+ return handle_phase0(c, fd)
1419
+ elif ui.phase == 1:
1420
+ return handle_phase1(c, fd)
1421
+ elif ui.phase == 2:
1422
+ return handle_phase2(c, fd)
1423
+ elif ui.phase == 3:
1424
+ return handle_phase3(c, fd)
1425
+ elif ui.phase == 4:
1426
+ return handle_phase4(c, fd)
1427
+ return True
1428
+
1429
+ def handle_edit_input(c, fd):
1430
+ if c == '\x1b':
1431
+ if _sel.select([fd], [], [], 0.05)[0]:
1432
+ os.read(fd, 2)
1433
+ ui.mode = 'normal'
1434
+ ui.input_buf = ""
1435
+ return True
1436
+ if c in ('\r', '\n'):
1437
+ save_edit()
1438
+ ui.mode = 'normal'
1439
+ return True
1440
+ if c == '\x7f' or c == '\x08':
1441
+ ui.input_buf = ui.input_buf[:-1]
1442
+ return True
1443
+ if c >= ' ' and c <= '~':
1444
+ ui.input_buf += c
1445
+ return True
1446
+
1447
+ def save_edit():
1448
+ buf = ui.input_buf.strip()
1449
+ if not buf:
1450
+ return
1451
+ target = ui.input_target
1452
+ if target.startswith("hyp:"):
1453
+ idx = int(target.split(":")[1])
1454
+ if idx < len(ui.hypotheses):
1455
+ ui.hypotheses[idx] = buf
1456
+ elif target == "new_hyp":
1457
+ ui.hypotheses.append(buf)
1458
+ ui.sel = len(ui.hypotheses) - 1
1459
+ elif target.startswith("persona_name:"):
1460
+ idx = int(target.split(":")[1])
1461
+ if idx < len(ui.personas):
1462
+ ui.personas[idx]['name'] = buf
1463
+
1464
+ def nav_up():
1465
+ if ui.sel > 0:
1466
+ ui.sel -= 1
1467
+ def nav_down():
1468
+ mx = get_max_sel()
1469
+ if ui.sel < mx:
1470
+ ui.sel += 1
1471
+ def nav_right():
1472
+ if ui.phase == 4:
1473
+ ui.review_tab = (ui.review_tab + 1) % 4
1474
+ ui.sel = 0; ui.scroll = 0
1475
+ def nav_left():
1476
+ if ui.phase == 4:
1477
+ ui.review_tab = (ui.review_tab - 1) % 4
1478
+ ui.sel = 0; ui.scroll = 0
1479
+
1480
+ def get_max_sel():
1481
+ if ui.phase == 1: return max(0, len(ui.hypotheses) - 1)
1482
+ elif ui.phase == 2: return max(0, len(ui.personas) - 1)
1483
+ elif ui.phase == 4:
1484
+ if ui.review_tab == 0: return max(0, len(ui.traces) - 1)
1485
+ elif ui.review_tab == 1: return max(0, len(ui.all_insights) - 1)
1486
+ elif ui.review_tab == 2: return max(0, len(ui.all_files) - 1)
1487
+ return 0
1488
+
1489
+ def handle_phase0(c, fd):
1490
+ if c in ('\r', '\n'):
1491
+ advance_phase()
1492
+ return True
1493
+
1494
+ def handle_phase1(c, fd):
1495
+ if c == 'j': nav_down()
1496
+ elif c == 'k': nav_up()
1497
+ elif c == 'e' and not ui.generating and ui.hypotheses:
1498
+ ui.mode = 'edit'
1499
+ ui.input_target = f"hyp:{ui.sel}"
1500
+ ui.input_buf = ui.hypotheses[ui.sel] if ui.sel < len(ui.hypotheses) else ""
1501
+ elif c == 'd' and not ui.generating and len(ui.hypotheses) > 1:
1502
+ del ui.hypotheses[ui.sel]
1503
+ ui.sel = clamp(ui.sel, 0, len(ui.hypotheses) - 1)
1504
+ elif c == '+' and not ui.generating:
1505
+ ui.mode = 'edit'
1506
+ ui.input_target = "new_hyp"
1507
+ ui.input_buf = ""
1508
+ elif c == 'r' and not ui.generating:
1509
+ threading.Thread(target=do_generate_hypotheses, daemon=True).start()
1510
+ elif c in ('\r', '\n') and not ui.generating:
1511
+ advance_phase()
1512
+ return True
1513
+
1514
+ def handle_phase2(c, fd):
1515
+ if c == 'j': nav_down()
1516
+ elif c == 'k': nav_up()
1517
+ elif c == 'e' and not ui.generating and ui.personas:
1518
+ ui.mode = 'edit'
1519
+ ui.input_target = f"persona_name:{ui.sel}"
1520
+ ui.input_buf = ui.personas[ui.sel]['name'] if ui.sel < len(ui.personas) else ""
1521
+ elif c == 'r' and not ui.generating:
1522
+ threading.Thread(target=do_generate_personas, daemon=True).start()
1523
+ elif c == 's' and not ui.generating and ui.personas:
1524
+ p = ui.personas[ui.sel]
1525
+ p['hypothesis_idx'] = (p['hypothesis_idx'] + 1) % len(ui.hypotheses)
1526
+ elif c in ('\r', '\n') and not ui.generating:
1527
+ advance_phase()
1528
+ return True
1529
+
1530
+ def handle_phase3(c, fd):
1531
+ if c == 's':
1532
+ ui.exec_skip = True
1533
+ ui.agent_ui['skip'] = True
1534
+ elif c == 'p':
1535
+ ui.exec_paused = not ui.exec_paused
1536
+ ui.agent_ui['paused'] = ui.exec_paused
1537
+ ui.status = "Paused" if ui.exec_paused else "Resumed"
1538
+ elif c == 'x':
1539
+ ui.end_cycles = True
1540
+ ui.status = "Ending review cycles after current operation..."
1541
+ elif c == 'j':
1542
+ ui.exec_scroll = max(0, ui.exec_scroll - 1)
1543
+ elif c == 'k':
1544
+ ui.exec_scroll += 1
1545
+ return True
1546
+
1547
+ def handle_phase4(c, fd):
1548
+ if c == 'j': nav_down()
1549
+ elif c == 'k': nav_up()
1550
+ elif c == '\t' or c == 'l': nav_right()
1551
+ elif c == 'h': nav_left()
1552
+ elif c == 's' and ui.review_tab == 1 and ui.sel < len(ui.all_insights):
1553
+ if ui.sel in ui.starred: ui.starred.discard(ui.sel)
1554
+ else: ui.starred.add(ui.sel)
1555
+ elif c in ('\r', '\n'):
1556
+ show_full_item(fd)
1557
+ return True
1558
+
1559
+ def show_full_item(fd):
1560
+ content = ""
1561
+ if ui.review_tab == 0 and ui.sel < len(ui.traces):
1562
+ t = ui.traces[ui.sel]
1563
+ content = f"Agent: {t.agent_name}\nHypothesis: {t.hypothesis}\nSuccess: {t.was_successful}\nPersona: {t.agent_persona}\n\n"
1564
+ for s in t.steps:
1565
+ content += f"--- Step {s.step} ---\nThought: {s.thought[:500]}\nAction: {s.action[:300]}\nOutcome: {s.outcome[:300]}\n\n"
1566
+ if t.final_files:
1567
+ content += f"Files: {', '.join(t.final_files.keys())}\n"
1568
+ elif ui.review_tab == 1 and ui.sel < len(ui.all_insights):
1569
+ content = ui.all_insights[ui.sel]
1570
+ elif ui.review_tab == 2 and ui.sel < len(ui.all_files):
1571
+ f = ui.all_files[ui.sel]
1572
+ content = f"File: {f['name']}\nAgent: {f['agent']}\n\n{f.get('content', 'N/A')}"
1573
+ elif ui.review_tab == 3:
1574
+ content = ui.paper
1575
+
1576
+ if not content:
1577
+ return
1578
+
1579
+ width, height = get_size()
1580
+ lines = wrap_text(content, width - 4)
1581
+ scroll = 0
1582
+
1583
+ sys.stdout.write('\033[2J\033[H')
1584
+ while True:
1585
+ sys.stdout.write('\033[H')
1586
+ for i in range(height - 2):
1587
+ idx = scroll + i
1588
+ sys.stdout.write(f'\033[{i+1};1H\033[K')
1589
+ if idx < len(lines):
1590
+ sys.stdout.write(f' {lines[idx][:width-4]}')
1591
+ sys.stdout.write(f'\033[{height};1H\033[K\033[7m j/k:Scroll q/Enter:Back [{scroll+1}-{min(scroll+height-2, len(lines))}/{len(lines)}] \033[0m')
1592
+ sys.stdout.flush()
1593
+
1594
+ c = os.read(fd, 1).decode('latin-1')
1595
+ if c in ('q', '\r', '\n'):
1596
+ break
1597
+ if c == '\x1b':
1598
+ if _sel.select([fd], [], [], 0.05)[0]:
1599
+ c2 = os.read(fd, 1).decode('latin-1')
1600
+ if c2 == '[':
1601
+ c3 = os.read(fd, 1).decode('latin-1')
1602
+ if c3 == 'A': scroll = max(0, scroll - 1)
1603
+ elif c3 == 'B': scroll = min(max(0, len(lines) - (height - 2)), scroll + 1)
1604
+ else:
1605
+ break
1606
+ elif c == 'k': scroll = max(0, scroll - 1)
1607
+ elif c == 'j': scroll = min(max(0, len(lines) - (height - 2)), scroll + 1)
1608
+
1609
+ def advance_phase():
1610
+ if ui.phase == 0:
1611
+ ui.phase = 1; ui.sel = 0; ui.scroll = 0; ui.error = ""
1612
+ threading.Thread(target=do_generate_hypotheses, daemon=True).start()
1613
+ if ui.auto_mode:
1614
+ threading.Thread(target=_auto_wait_then_advance, args=(1,), daemon=True).start()
1615
+ elif ui.phase == 1:
1616
+ ui.phase = 2; ui.sel = 0; ui.scroll = 0; ui.error = ""
1617
+ threading.Thread(target=do_generate_personas, daemon=True).start()
1618
+ if ui.auto_mode:
1619
+ threading.Thread(target=_auto_wait_then_advance, args=(2,), daemon=True).start()
1620
+ elif ui.phase == 2:
1621
+ ui.phase = 3; ui.sel = 0; ui.scroll = 0; ui.error = ""
1622
+ threading.Thread(target=do_run_all_agents, daemon=True).start()
1623
+
1624
+ def _auto_wait_then_advance(expected_phase):
1625
+ while ui.generating:
1626
+ time.sleep(0.3)
1627
+ if ui.phase == expected_phase and ui.auto_mode:
1628
+ time.sleep(0.5)
1629
+ advance_phase()
1630
+
1631
+ # ========== Main TUI Loop ==========
1632
+ if not sys.stdin.isatty():
1633
+ os.chdir(_orig_cwd)
1634
+ context['output'] = "Alicanto requires an interactive terminal."
1635
+ context['messages'] = messages
1636
+ exit()
1637
+
1638
+ ui.phase = 0
1639
+ ui.status = "Press Enter to begin, A for auto mode"
1640
+
1641
+ fd = sys.stdin.fileno()
1642
+ old_settings = termios.tcgetattr(fd)
1643
+
1644
+ try:
1645
+ tty.setcbreak(fd)
1646
+ sys.stdout.write('\033[?25l')
1647
+ sys.stdout.flush()
1648
+ render_screen()
1649
+
1650
+ running = True
1651
+ while running:
1652
+ if ui.generating:
1653
+ if _sel.select([fd], [], [], 0.3)[0]:
1654
+ c = os.read(fd, 1).decode('latin-1')
1655
+ running = handle_input(c, fd)
1656
+ else:
1657
+ if _sel.select([fd], [], [], 0.5)[0]:
1658
+ c = os.read(fd, 1).decode('latin-1')
1659
+ running = handle_input(c, fd)
1660
+ # Sync agent_ui state
1661
+ ui.agent_ui['skip'] = ui.exec_skip
1662
+ ui.agent_ui['paused'] = ui.exec_paused
1663
+ render_screen()
1664
+
1665
+ finally:
1666
+ termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
1667
+ sys.stdout.write('\033[?25h')
1668
+ sys.stdout.write('\033[2J\033[H')
1669
+ sys.stdout.flush()
1670
+ os.chdir(_orig_cwd)
1671
+
1672
+ if ui.traces:
1673
+ print("\033[1;36m=== ALICANTO RESEARCH COMPLETE ===\033[0m\n")
1674
+ print(f"Query: {ui.query}")
1675
+ print(f"Agents: {len(ui.traces)}")
1676
+ for i, trace in enumerate(ui.traces):
1677
+ status = "SUCCESS" if trace.was_successful else "no files"
1678
+ print(f"\n{i+1}. \033[1m{trace.agent_name}\033[0m [{status}]")
1679
+ print(f" Hypothesis: {trace.hypothesis[:100]}")
1680
+ print(f" Steps: {len(trace.steps)} Files: {list(trace.final_files.keys())}")
1681
+ if ui.paper:
1682
+ print(f"\n\033[1;32m--- Paper ---\033[0m")
1683
+ print(ui.paper[:2000])
1684
+ print(f"\n\033[90mOutput directory: {_work_dir}\033[0m")
1685
+
1686
+ context['output'] = ui.paper or "Alicanto session ended."
1687
+ context['messages'] = messages
1688
+ context['alicanto_result'] = {
1689
+ 'query': ui.query,
1690
+ 'hypotheses': ui.hypotheses,
1691
+ 'personas': [{'name': p.get('name'), 'birth_year': p.get('birth_year'), 'persona_text': p.get('persona_text')} for p in ui.personas],
1692
+ 'traces': [{'agent': t.agent_name, 'hypothesis': t.hypothesis, 'success': t.was_successful, 'steps': len(t.steps), 'files': list(t.final_files.keys())} for t in ui.traces],
1693
+ 'paper': ui.paper,
1694
+ }