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