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
@@ -1,18 +1,15 @@
1
1
  jinx_name: alicanto
2
- description: Deep research mode - multi-perspective exploration with gold insights and cliff warnings
2
+ description: Deep research mode - multi-agent hypothesis exploration with approval-gated TUI pipeline
3
+ interactive: true
3
4
  npc: forenpc
4
5
  inputs:
5
6
  - query: null
6
- - num_npcs: 5
7
- - depth: 3
7
+ - num_npcs: 3
8
8
  - model: null
9
9
  - provider: null
10
- - max_steps: 20
11
- - skip_research: true
12
- - exploration: 0.3
13
- - creativity: 0.5
10
+ - max_steps: 10
11
+ - num_cycles: 3
14
12
  - format: report
15
- - browse: false
16
13
 
17
14
  steps:
18
15
  - name: alicanto_research
@@ -22,335 +19,1676 @@ steps:
22
19
  import sys
23
20
  import tty
24
21
  import termios
25
- from termcolor import colored
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
26
37
 
27
38
  from npcpy.llm_funcs import get_llm_response
28
- from npcpy.data.web import search_web
29
39
  from npcpy.npc_compiler import NPC
30
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
+
31
53
  npc = context.get('npc')
32
54
  team = context.get('team')
33
55
  messages = context.get('messages', [])
34
56
 
35
- # Resolve npc if it's a string (npc name) rather than NPC object
36
57
  if isinstance(npc, str) and team:
37
58
  npc = team.get(npc) if hasattr(team, 'get') else None
38
59
  elif isinstance(npc, str):
39
- npc = None # Can't use string npc without team to resolve it
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')
40
67
 
41
- # ========== TUI Helper Functions ==========
42
- def get_terminal_size():
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():
43
74
  try:
44
- size = os.get_terminal_size()
45
- return size.columns, size.lines
75
+ s = os.get_terminal_size()
76
+ return s.columns, s.lines
46
77
  except:
47
78
  return 80, 24
48
79
 
49
- def research_tui_browser(result):
50
- """Interactive TUI browser for research results"""
51
- perspectives = result.get('perspectives', '').split('\n')
52
- insights = result.get('insights', [])
53
- gold = result.get('gold', [])
54
- cliffs = result.get('cliffs', [])
55
- report = result.get('report', '')
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
56
88
 
57
- # Build tabs
58
- tabs = ['Gold', 'Cliffs', 'Insights', 'Report']
59
- current_tab = 0
89
+ def clamp(val, lo, hi):
90
+ return max(lo, min(val, hi))
60
91
 
61
- width, height = get_terminal_size()
62
- selected = 0
63
- scroll = 0
64
- list_height = height - 5
92
+ # ========== Data Classes (matching original) ==========
93
+ @dataclass
94
+ class ResearchStep:
95
+ step: int
96
+ thought: str
97
+ action: str
98
+ outcome: str
65
99
 
66
- fd = sys.stdin.fileno()
67
- old_settings = termios.tcgetattr(fd)
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
68
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."""
69
165
  try:
70
- tty.setcbreak(fd)
71
- sys.stdout.write('\033[?25l')
72
- sys.stdout.write('\033[2J\033[H')
73
-
74
- while True:
75
- width, height = get_terminal_size()
76
- list_height = height - 5
77
-
78
- # Get current content
79
- if current_tab == 0: # Gold
80
- items = gold if gold else ['No gold insights marked']
81
- elif current_tab == 1: # Cliffs
82
- items = cliffs if cliffs else ['No cliff warnings marked']
83
- elif current_tab == 2: # Insights
84
- items = [i[:200] for i in insights] if insights else ['No insights yet']
85
- else: # Report
86
- items = report.split('\n') if report else ['No report generated']
87
-
88
- if selected >= len(items):
89
- selected = max(0, len(items) - 1)
90
-
91
- if selected < scroll:
92
- scroll = selected
93
- elif selected >= scroll + list_height:
94
- scroll = selected - list_height + 1
95
-
96
- sys.stdout.write('\033[H')
97
-
98
- # Tab bar
99
- tab_bar = " "
100
- for i, tab in enumerate(tabs):
101
- if i == current_tab:
102
- tab_bar += f'\033[43;30;1m {tab} \033[0m '
103
- else:
104
- tab_bar += f'\033[90m {tab} \033[0m '
105
- sys.stdout.write(f'{tab_bar.ljust(width)}\n')
106
- sys.stdout.write(f'\033[90m{"─" * width}\033[0m\n')
107
-
108
- # Content
109
- for i in range(list_height):
110
- idx = scroll + i
111
- sys.stdout.write(f'\033[{3+i};1H\033[K')
112
- if idx >= len(items):
113
- continue
114
-
115
- line = str(items[idx])[:width-2]
116
- if current_tab in [0, 1, 2] and idx == selected:
117
- sys.stdout.write(f'\033[7;1m>{line.ljust(width-2)}\033[0m')
118
- else:
119
- # Color gold/cliff markers
120
- if '[GOLD]' in line:
121
- sys.stdout.write(f'\033[33m {line}\033[0m')
122
- elif '[CLIFF]' in line:
123
- sys.stdout.write(f'\033[31m {line}\033[0m')
124
- else:
125
- sys.stdout.write(f' {line}')
126
-
127
- # Status bar
128
- sys.stdout.write(f'\033[{height-2};1H\033[K\033[90m{"─" * width}\033[0m')
129
- counts = f"Gold: {len(gold)} | Cliffs: {len(cliffs)} | Insights: {len(insights)}"
130
- sys.stdout.write(f'\033[{height-1};1H\033[K {counts}'.ljust(width)[:width])
131
- sys.stdout.write(f'\033[{height};1H\033[K\033[43;30m h/l:Tabs j/k:Nav Enter:View q:Quit [{selected+1}/{len(items)}] \033[0m')
132
-
133
- sys.stdout.flush()
134
-
135
- c = sys.stdin.read(1)
136
-
137
- if c == '\x1b':
138
- c2 = sys.stdin.read(1)
139
- if c2 == '[':
140
- c3 = sys.stdin.read(1)
141
- if c3 == 'A' and selected > 0:
142
- selected -= 1
143
- elif c3 == 'B' and selected < len(items) - 1:
144
- selected += 1
145
- elif c3 == 'C': # Right
146
- current_tab = (current_tab + 1) % len(tabs)
147
- selected = 0
148
- scroll = 0
149
- elif c3 == 'D': # Left
150
- current_tab = (current_tab - 1) % len(tabs)
151
- selected = 0
152
- scroll = 0
153
- else:
154
- return
155
- continue
156
-
157
- if c == 'q' or c == '\x03':
158
- return
159
- elif c == 'k' and selected > 0:
160
- selected -= 1
161
- elif c == 'j' and selected < len(items) - 1:
162
- selected += 1
163
- elif c == 'h':
164
- current_tab = (current_tab - 1) % len(tabs)
165
- selected = 0
166
- scroll = 0
167
- elif c == 'l':
168
- current_tab = (current_tab + 1) % len(tabs)
169
- selected = 0
170
- scroll = 0
171
- elif c in ('\r', '\n') and current_tab < 3:
172
- # Show full item
173
- item = items[selected] if selected < len(items) else ''
174
- print(f'\033[2J\033[H{item}\n\nPress any key to continue...')
175
- sys.stdout.flush()
176
- sys.stdin.read(1)
177
- sys.stdout.write('\033[2J\033[H')
166
+ return "\n".join(os.listdir(directory))
167
+ except:
168
+ return "Error listing directory."
178
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}")
179
183
  finally:
180
- termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
181
- sys.stdout.write('\033[?25h')
182
- sys.stdout.write('\033[2J\033[H')
183
- sys.stdout.flush()
184
+ sys.stdout = _old_stdout
185
+ sys.stderr = _old_stderr
186
+ return _capture.getvalue()[:5000] if _capture.getvalue().strip() else "(no output)"
184
187
 
185
- query = context.get('query')
186
- num_npcs = int(context.get('num_npcs', 5))
187
- depth = int(context.get('depth', 3))
188
- max_steps = int(context.get('max_steps', 20))
189
- skip_research = context.get('skip_research', True)
190
- exploration = float(context.get('exploration', 0.3))
191
- creativity = float(context.get('creativity', 0.5))
192
- output_format = context.get('format', 'report')
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."""
193
752
 
194
- # Handle case where npc might be a string (npc name) or NPC object
195
- model = context.get('model') or (npc.model if npc and hasattr(npc, 'model') else 'gemini-1.5-pro')
196
- provider = context.get('provider') or (npc.provider if npc and hasattr(npc, 'provider') else 'gemini')
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")
197
768
 
198
- if not query:
199
- context['output'] = """Usage: /alicanto <research query>
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>
200
922
 
201
923
  Options:
202
- --num-npcs N Number of research perspectives (default: 5)
203
- --depth N Research depth (default: 3)
204
- --max-steps N Maximum research steps (default: 20)
205
- --exploration F Exploration factor 0-1 (default: 0.3)
206
- --creativity F Creativity factor 0-1 (default: 0.5)
207
- --format FORMAT Output: report|summary|full (default: report)
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
208
929
 
209
930
  Example: /alicanto What are the latest advances in quantum computing?"""
210
- context['messages'] = messages
211
- exit()
931
+ context['messages'] = messages
932
+ exit()
212
933
 
213
- print(f"""
214
- █████╗ ██╗ ██╗ ██████╗ █████╗ ███╗ ██╗████████╗ ██████╗
215
- ██╔══██╗██║ ██║██╔════╝██╔══██╗████╗ ██║╚══██╔══╝██╔═══██╗
216
- ███████║██║ ██║██║ ███████║██╔██╗ ██║ ██║ ██║ ██║
217
- ██╔══██║██║ ██║██║ ██╔══██║██║╚██╗██║ ██║ ██║ ██║
218
- ██║ ██║███████╗██║╚██████╗██║ ██║██║ ╚████║ ██║ ╚██████╔╝
219
- ╚═╝ ╚═╝╚══════╝╚═╝ ╚═════╝╚═╝ ╚═╝╚═╝ ╚═══╝ ╚═╝ ╚═════╝
220
-
221
- Deep Research Mode
222
- Query: {query}
223
- Perspectives: {num_npcs} | Depth: {depth} | Max Steps: {max_steps}
224
- """)
225
-
226
- # Generate research perspectives
227
- perspectives_prompt = f"""Generate {num_npcs} distinct research perspectives for investigating: "{query}"
228
-
229
- For each perspective, provide:
230
- 1. Name (a descriptive title)
231
- 2. Approach (how this perspective would investigate)
232
- 3. Key questions to explore
233
-
234
- Return as a numbered list."""
235
-
236
- print(colored("Generating research perspectives...", "cyan"))
237
- resp = get_llm_response(
238
- perspectives_prompt,
239
- model=model,
240
- provider=provider,
241
- npc=npc
242
- )
243
- perspectives = str(resp.get('response', ''))
244
- print(perspectives)
245
-
246
- # Conduct web research if not skipped
247
- research_findings = ""
248
- if not skip_research:
249
- print(colored("\nConducting web research...", "cyan"))
934
+ # ========== Generation Functions ==========
935
+ def do_generate_hypotheses():
936
+ ui.status = "Generating hypotheses..."
937
+ ui.generating = True
938
+ ui.error = ""
250
939
  try:
251
- search_results = search_web(query, n_results=5)
252
- if search_results:
253
- research_findings = "\n\nWeb Research Findings:\n"
254
- for i, result in enumerate(search_results[:5], 1):
255
- title = result.get('title', 'No title')
256
- snippet = result.get('snippet', result.get('body', ''))[:200]
257
- research_findings += f"\n{i}. {title}\n {snippet}...\n"
258
- print(colored(f"Found {len(search_results)} sources", "green"))
259
- except Exception as e:
260
- print(colored(f"Web search error: {e}", "yellow"))
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.
261
954
 
262
- # Multi-step exploration from each perspective
263
- all_insights = []
264
- gold_insights = [] # Key valuable findings
265
- cliff_warnings = [] # Potential pitfalls or caveats
955
+ Here is an example of the expected input and output format:
956
+ {one_shot}
266
957
 
267
- for step in range(min(depth, max_steps)):
268
- print(colored(f"\n--- Research Depth {step + 1}/{depth} ---", "cyan"))
958
+ Return ONLY the JSON object."""
269
959
 
270
- explore_prompt = f"""Research query: "{query}"
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)]
271
975
 
272
- Perspectives generated:
273
- {perspectives}
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"
274
983
 
275
- {research_findings}
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"
276
1008
 
277
- Previous insights: {all_insights[-3:] if all_insights else 'None yet'}
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}
278
1015
 
279
- For depth level {step + 1}:
280
- 1. Explore deeper implications from each perspective
281
- 2. Identify GOLD insights (valuable, non-obvious findings) - mark with [GOLD]
282
- 3. Identify CLIFF warnings (pitfalls, caveats, risks) - mark with [CLIFF]
283
- 4. Connect insights across perspectives
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}"
284
1028
 
285
- Exploration factor: {exploration} (higher = more diverse exploration)
286
- Creativity factor: {creativity} (higher = more novel connections)"""
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]
287
1031
 
288
- resp = get_llm_response(
289
- explore_prompt,
290
- model=model,
291
- provider=provider,
292
- temperature=creativity,
293
- npc=npc
1032
+ coordinator = NPC(
1033
+ name="Alicanto",
1034
+ model=model, provider=provider,
1035
+ primary_directive=_alicanto_directive,
1036
+ tools=coord_tools
294
1037
  )
295
1038
 
296
- step_insights = str(resp.get('response', ''))
297
- print(step_insights)
298
-
299
- # Extract gold and cliff markers
300
- if '[GOLD]' in step_insights:
301
- gold_insights.extend([line.strip() for line in step_insights.split('\n') if '[GOLD]' in line])
302
- if '[CLIFF]' in step_insights:
303
- cliff_warnings.extend([line.strip() for line in step_insights.split('\n') if '[CLIFF]' in line])
304
-
305
- all_insights.append(step_insights)
306
-
307
- # Generate final synthesis
308
- print(colored("\n--- Synthesizing Research ---", "cyan"))
309
-
310
- synthesis_prompt = f"""Synthesize research on: "{query}"
311
-
312
- All insights gathered:
313
- {chr(10).join(all_insights)}
314
-
315
- Gold insights identified:
316
- {chr(10).join(gold_insights) if gold_insights else 'None explicitly marked'}
317
-
318
- Cliff warnings identified:
319
- {chr(10).join(cliff_warnings) if cliff_warnings else 'None explicitly marked'}
320
-
321
- Generate a {output_format} that:
322
- 1. Summarizes key findings
323
- 2. Highlights the most valuable insights (gold)
324
- 3. Notes important caveats and risks (cliffs)
325
- 4. Provides actionable conclusions"""
326
-
327
- resp = get_llm_response(
328
- synthesis_prompt,
329
- model=model,
330
- provider=provider,
331
- npc=npc
332
- )
333
-
334
- final_report = str(resp.get('response', ''))
335
- print("\n" + "="*60)
336
- print(colored("ALICANTO RESEARCH REPORT", "green", attrs=['bold']))
337
- print("="*60)
338
- print(final_report)
339
-
340
- alicanto_result = {
341
- 'query': query,
342
- 'perspectives': perspectives,
343
- 'insights': all_insights,
344
- 'gold': gold_insights,
345
- 'cliffs': cliff_warnings,
346
- 'report': final_report
347
- }
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")
348
1044
 
349
- context['output'] = final_report
350
- context['messages'] = messages
351
- context['alicanto_result'] = alicanto_result
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
352
1223
 
353
- # Launch interactive browser automatically
354
- if gold_insights or cliff_warnings or all_insights:
355
- print(colored("\nLaunching results browser...", "cyan"))
356
- research_tui_browser(alicanto_result)
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
+ }