npcsh 0.1.2__py3-none-any.whl → 1.1.13__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 (143) hide show
  1. npcsh/_state.py +3508 -0
  2. npcsh/alicanto.py +65 -0
  3. npcsh/build.py +291 -0
  4. npcsh/completion.py +206 -0
  5. npcsh/config.py +163 -0
  6. npcsh/corca.py +50 -0
  7. npcsh/execution.py +185 -0
  8. npcsh/guac.py +46 -0
  9. npcsh/mcp_helpers.py +357 -0
  10. npcsh/mcp_server.py +299 -0
  11. npcsh/npc.py +323 -0
  12. npcsh/npc_team/alicanto.npc +2 -0
  13. npcsh/npc_team/alicanto.png +0 -0
  14. npcsh/npc_team/corca.npc +12 -0
  15. npcsh/npc_team/corca.png +0 -0
  16. npcsh/npc_team/corca_example.png +0 -0
  17. npcsh/npc_team/foreman.npc +7 -0
  18. npcsh/npc_team/frederic.npc +6 -0
  19. npcsh/npc_team/frederic4.png +0 -0
  20. npcsh/npc_team/guac.png +0 -0
  21. npcsh/npc_team/jinxs/code/python.jinx +11 -0
  22. npcsh/npc_team/jinxs/code/sh.jinx +34 -0
  23. npcsh/npc_team/jinxs/code/sql.jinx +16 -0
  24. npcsh/npc_team/jinxs/modes/alicanto.jinx +194 -0
  25. npcsh/npc_team/jinxs/modes/corca.jinx +249 -0
  26. npcsh/npc_team/jinxs/modes/guac.jinx +317 -0
  27. npcsh/npc_team/jinxs/modes/plonk.jinx +214 -0
  28. npcsh/npc_team/jinxs/modes/pti.jinx +170 -0
  29. npcsh/npc_team/jinxs/modes/spool.jinx +161 -0
  30. npcsh/npc_team/jinxs/modes/wander.jinx +186 -0
  31. npcsh/npc_team/jinxs/modes/yap.jinx +262 -0
  32. npcsh/npc_team/jinxs/npc_studio/npc-studio.jinx +77 -0
  33. npcsh/npc_team/jinxs/utils/agent.jinx +17 -0
  34. npcsh/npc_team/jinxs/utils/chat.jinx +44 -0
  35. npcsh/npc_team/jinxs/utils/cmd.jinx +44 -0
  36. npcsh/npc_team/jinxs/utils/compress.jinx +140 -0
  37. npcsh/npc_team/jinxs/utils/core/build.jinx +65 -0
  38. npcsh/npc_team/jinxs/utils/core/compile.jinx +50 -0
  39. npcsh/npc_team/jinxs/utils/core/help.jinx +52 -0
  40. npcsh/npc_team/jinxs/utils/core/init.jinx +41 -0
  41. npcsh/npc_team/jinxs/utils/core/jinxs.jinx +32 -0
  42. npcsh/npc_team/jinxs/utils/core/set.jinx +40 -0
  43. npcsh/npc_team/jinxs/utils/edit_file.jinx +94 -0
  44. npcsh/npc_team/jinxs/utils/load_file.jinx +35 -0
  45. npcsh/npc_team/jinxs/utils/ots.jinx +61 -0
  46. npcsh/npc_team/jinxs/utils/roll.jinx +68 -0
  47. npcsh/npc_team/jinxs/utils/sample.jinx +56 -0
  48. npcsh/npc_team/jinxs/utils/search.jinx +130 -0
  49. npcsh/npc_team/jinxs/utils/serve.jinx +26 -0
  50. npcsh/npc_team/jinxs/utils/sleep.jinx +116 -0
  51. npcsh/npc_team/jinxs/utils/trigger.jinx +61 -0
  52. npcsh/npc_team/jinxs/utils/usage.jinx +33 -0
  53. npcsh/npc_team/jinxs/utils/vixynt.jinx +144 -0
  54. npcsh/npc_team/kadiefa.npc +3 -0
  55. npcsh/npc_team/kadiefa.png +0 -0
  56. npcsh/npc_team/npcsh.ctx +18 -0
  57. npcsh/npc_team/npcsh_sibiji.png +0 -0
  58. npcsh/npc_team/plonk.npc +2 -0
  59. npcsh/npc_team/plonk.png +0 -0
  60. npcsh/npc_team/plonkjr.npc +2 -0
  61. npcsh/npc_team/plonkjr.png +0 -0
  62. npcsh/npc_team/sibiji.npc +3 -0
  63. npcsh/npc_team/sibiji.png +0 -0
  64. npcsh/npc_team/spool.png +0 -0
  65. npcsh/npc_team/yap.png +0 -0
  66. npcsh/npcsh.py +296 -112
  67. npcsh/parsing.py +118 -0
  68. npcsh/plonk.py +54 -0
  69. npcsh/pti.py +54 -0
  70. npcsh/routes.py +139 -0
  71. npcsh/spool.py +48 -0
  72. npcsh/ui.py +199 -0
  73. npcsh/wander.py +62 -0
  74. npcsh/yap.py +50 -0
  75. npcsh-1.1.13.data/data/npcsh/npc_team/agent.jinx +17 -0
  76. npcsh-1.1.13.data/data/npcsh/npc_team/alicanto.jinx +194 -0
  77. npcsh-1.1.13.data/data/npcsh/npc_team/alicanto.npc +2 -0
  78. npcsh-1.1.13.data/data/npcsh/npc_team/alicanto.png +0 -0
  79. npcsh-1.1.13.data/data/npcsh/npc_team/build.jinx +65 -0
  80. npcsh-1.1.13.data/data/npcsh/npc_team/chat.jinx +44 -0
  81. npcsh-1.1.13.data/data/npcsh/npc_team/cmd.jinx +44 -0
  82. npcsh-1.1.13.data/data/npcsh/npc_team/compile.jinx +50 -0
  83. npcsh-1.1.13.data/data/npcsh/npc_team/compress.jinx +140 -0
  84. npcsh-1.1.13.data/data/npcsh/npc_team/corca.jinx +249 -0
  85. npcsh-1.1.13.data/data/npcsh/npc_team/corca.npc +12 -0
  86. npcsh-1.1.13.data/data/npcsh/npc_team/corca.png +0 -0
  87. npcsh-1.1.13.data/data/npcsh/npc_team/corca_example.png +0 -0
  88. npcsh-1.1.13.data/data/npcsh/npc_team/edit_file.jinx +94 -0
  89. npcsh-1.1.13.data/data/npcsh/npc_team/foreman.npc +7 -0
  90. npcsh-1.1.13.data/data/npcsh/npc_team/frederic.npc +6 -0
  91. npcsh-1.1.13.data/data/npcsh/npc_team/frederic4.png +0 -0
  92. npcsh-1.1.13.data/data/npcsh/npc_team/guac.jinx +317 -0
  93. npcsh-1.1.13.data/data/npcsh/npc_team/guac.png +0 -0
  94. npcsh-1.1.13.data/data/npcsh/npc_team/help.jinx +52 -0
  95. npcsh-1.1.13.data/data/npcsh/npc_team/init.jinx +41 -0
  96. npcsh-1.1.13.data/data/npcsh/npc_team/jinxs.jinx +32 -0
  97. npcsh-1.1.13.data/data/npcsh/npc_team/kadiefa.npc +3 -0
  98. npcsh-1.1.13.data/data/npcsh/npc_team/kadiefa.png +0 -0
  99. npcsh-1.1.13.data/data/npcsh/npc_team/load_file.jinx +35 -0
  100. npcsh-1.1.13.data/data/npcsh/npc_team/npc-studio.jinx +77 -0
  101. npcsh-1.1.13.data/data/npcsh/npc_team/npcsh.ctx +18 -0
  102. npcsh-1.1.13.data/data/npcsh/npc_team/npcsh_sibiji.png +0 -0
  103. npcsh-1.1.13.data/data/npcsh/npc_team/ots.jinx +61 -0
  104. npcsh-1.1.13.data/data/npcsh/npc_team/plonk.jinx +214 -0
  105. npcsh-1.1.13.data/data/npcsh/npc_team/plonk.npc +2 -0
  106. npcsh-1.1.13.data/data/npcsh/npc_team/plonk.png +0 -0
  107. npcsh-1.1.13.data/data/npcsh/npc_team/plonkjr.npc +2 -0
  108. npcsh-1.1.13.data/data/npcsh/npc_team/plonkjr.png +0 -0
  109. npcsh-1.1.13.data/data/npcsh/npc_team/pti.jinx +170 -0
  110. npcsh-1.1.13.data/data/npcsh/npc_team/python.jinx +11 -0
  111. npcsh-1.1.13.data/data/npcsh/npc_team/roll.jinx +68 -0
  112. npcsh-1.1.13.data/data/npcsh/npc_team/sample.jinx +56 -0
  113. npcsh-1.1.13.data/data/npcsh/npc_team/search.jinx +130 -0
  114. npcsh-1.1.13.data/data/npcsh/npc_team/serve.jinx +26 -0
  115. npcsh-1.1.13.data/data/npcsh/npc_team/set.jinx +40 -0
  116. npcsh-1.1.13.data/data/npcsh/npc_team/sh.jinx +34 -0
  117. npcsh-1.1.13.data/data/npcsh/npc_team/sibiji.npc +3 -0
  118. npcsh-1.1.13.data/data/npcsh/npc_team/sibiji.png +0 -0
  119. npcsh-1.1.13.data/data/npcsh/npc_team/sleep.jinx +116 -0
  120. npcsh-1.1.13.data/data/npcsh/npc_team/spool.jinx +161 -0
  121. npcsh-1.1.13.data/data/npcsh/npc_team/spool.png +0 -0
  122. npcsh-1.1.13.data/data/npcsh/npc_team/sql.jinx +16 -0
  123. npcsh-1.1.13.data/data/npcsh/npc_team/trigger.jinx +61 -0
  124. npcsh-1.1.13.data/data/npcsh/npc_team/usage.jinx +33 -0
  125. npcsh-1.1.13.data/data/npcsh/npc_team/vixynt.jinx +144 -0
  126. npcsh-1.1.13.data/data/npcsh/npc_team/wander.jinx +186 -0
  127. npcsh-1.1.13.data/data/npcsh/npc_team/yap.jinx +262 -0
  128. npcsh-1.1.13.data/data/npcsh/npc_team/yap.png +0 -0
  129. npcsh-1.1.13.dist-info/METADATA +522 -0
  130. npcsh-1.1.13.dist-info/RECORD +135 -0
  131. {npcsh-0.1.2.dist-info → npcsh-1.1.13.dist-info}/WHEEL +1 -1
  132. npcsh-1.1.13.dist-info/entry_points.txt +9 -0
  133. {npcsh-0.1.2.dist-info → npcsh-1.1.13.dist-info/licenses}/LICENSE +1 -1
  134. npcsh/command_history.py +0 -81
  135. npcsh/helpers.py +0 -36
  136. npcsh/llm_funcs.py +0 -295
  137. npcsh/main.py +0 -5
  138. npcsh/modes.py +0 -343
  139. npcsh/npc_compiler.py +0 -124
  140. npcsh-0.1.2.dist-info/METADATA +0 -99
  141. npcsh-0.1.2.dist-info/RECORD +0 -14
  142. npcsh-0.1.2.dist-info/entry_points.txt +0 -2
  143. {npcsh-0.1.2.dist-info → npcsh-1.1.13.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,249 @@
1
+ jinx_name: corca
2
+ description: MCP-powered agentic shell - LLM with tool use via MCP servers
3
+ inputs:
4
+ - mcp_server_path: null
5
+ - initial_command: null
6
+ - model: null
7
+ - provider: null
8
+
9
+ steps:
10
+ - name: corca_repl
11
+ engine: python
12
+ code: |
13
+ import os
14
+ import sys
15
+ import asyncio
16
+ import json
17
+ from contextlib import AsyncExitStack
18
+ from termcolor import colored
19
+
20
+ from npcpy.llm_funcs import get_llm_response
21
+ from npcpy.npc_sysenv import render_markdown, get_system_message
22
+
23
+ # MCP imports
24
+ try:
25
+ from mcp import ClientSession, StdioServerParameters
26
+ from mcp.client.stdio import stdio_client
27
+ MCP_AVAILABLE = True
28
+ except ImportError:
29
+ MCP_AVAILABLE = False
30
+ print(colored("MCP not available. Install with: pip install mcp-client", "yellow"))
31
+
32
+ npc = context.get('npc')
33
+ team = context.get('team')
34
+ messages = context.get('messages', [])
35
+ mcp_server_path = context.get('mcp_server_path')
36
+ initial_command = context.get('initial_command')
37
+
38
+ model = context.get('model') or (npc.model if npc else None)
39
+ provider = context.get('provider') or (npc.provider if npc else None)
40
+
41
+ # Use shared_context for MCP state
42
+ shared_ctx = npc.shared_context if npc and hasattr(npc, 'shared_context') else {}
43
+
44
+ print("""
45
+ ██████╗ ██████╗ ██████╗ ██████╗ █████╗
46
+ ██╔════╝██╔═══██╗██╔══██╗██╔════╝██╔══██╗
47
+ ██║ ██║ ██║██████╔╝██║ ███████║
48
+ ██║ ██║ ██║██╔══██╗██║ ██╔══██╗
49
+ ╚██████╗╚██████╔╝██║ ██║╚██████╗██║ ██║
50
+ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝
51
+ """)
52
+
53
+ npc_name = npc.name if npc else "corca"
54
+ print(f"Entering corca mode (NPC: {npc_name}). Type '/cq' to exit.")
55
+
56
+ # ========== MCP Connection Setup ==========
57
+ async def connect_mcp(server_path):
58
+ """Connect to MCP server and return tools"""
59
+ if not MCP_AVAILABLE:
60
+ return [], {}
61
+
62
+ abs_path = os.path.abspath(os.path.expanduser(server_path))
63
+ if not os.path.exists(abs_path):
64
+ print(colored(f"MCP server not found: {abs_path}", "red"))
65
+ return [], {}
66
+
67
+ try:
68
+ loop = asyncio.get_event_loop()
69
+ except RuntimeError:
70
+ loop = asyncio.new_event_loop()
71
+ asyncio.set_event_loop(loop)
72
+
73
+ exit_stack = AsyncExitStack()
74
+
75
+ if abs_path.endswith('.py'):
76
+ cmd_parts = [sys.executable, abs_path]
77
+ else:
78
+ cmd_parts = [abs_path]
79
+
80
+ server_params = StdioServerParameters(
81
+ command=cmd_parts[0],
82
+ args=[abs_path],
83
+ env=os.environ.copy()
84
+ )
85
+
86
+ stdio_transport = await exit_stack.enter_async_context(stdio_client(server_params))
87
+ session = await exit_stack.enter_async_context(ClientSession(*stdio_transport))
88
+ await session.initialize()
89
+
90
+ response = await session.list_tools()
91
+ tools_llm = []
92
+ tool_map = {}
93
+
94
+ if response.tools:
95
+ for mcp_tool in response.tools:
96
+ tool_def = {
97
+ "type": "function",
98
+ "function": {
99
+ "name": mcp_tool.name,
100
+ "description": mcp_tool.description or f"MCP tool: {mcp_tool.name}",
101
+ "parameters": getattr(mcp_tool, "inputSchema", {"type": "object", "properties": {}})
102
+ }
103
+ }
104
+ tools_llm.append(tool_def)
105
+
106
+ # Create sync wrapper for async tool call
107
+ def make_tool_func(tool_name, sess, lp):
108
+ async def call_tool(**kwargs):
109
+ cleaned = {k: (None if v == 'None' else v) for k, v in kwargs.items()}
110
+ result = await asyncio.wait_for(sess.call_tool(tool_name, cleaned), timeout=30.0)
111
+ return result
112
+ def sync_call(**kwargs):
113
+ return lp.run_until_complete(call_tool(**kwargs))
114
+ return sync_call
115
+
116
+ tool_map[mcp_tool.name] = make_tool_func(mcp_tool.name, session, loop)
117
+
118
+ # Store in shared context
119
+ shared_ctx['mcp_client'] = session
120
+ shared_ctx['mcp_tools'] = tools_llm
121
+ shared_ctx['mcp_tool_map'] = tool_map
122
+ shared_ctx['_mcp_exit_stack'] = exit_stack
123
+ shared_ctx['_mcp_loop'] = loop
124
+
125
+ print(colored(f"Connected to MCP server. Tools: {', '.join(tool_map.keys())}", "green"))
126
+ return tools_llm, tool_map
127
+
128
+ # Try to connect if server path provided
129
+ tools_llm = shared_ctx.get('mcp_tools', [])
130
+ tool_map = shared_ctx.get('mcp_tool_map', {})
131
+
132
+ if mcp_server_path and not tools_llm:
133
+ try:
134
+ loop = asyncio.get_event_loop()
135
+ except RuntimeError:
136
+ loop = asyncio.new_event_loop()
137
+ asyncio.set_event_loop(loop)
138
+ tools_llm, tool_map = loop.run_until_complete(connect_mcp(mcp_server_path))
139
+
140
+ # Find default MCP server if none provided
141
+ if not tools_llm:
142
+ default_paths = [
143
+ os.path.expanduser("~/.npcsh/npc_team/mcp_server.py"),
144
+ os.path.join(team.team_path, "mcp_server.py") if team and hasattr(team, 'team_path') else None,
145
+ ]
146
+ for path in default_paths:
147
+ if path and os.path.exists(path):
148
+ try:
149
+ loop = asyncio.get_event_loop()
150
+ except RuntimeError:
151
+ loop = asyncio.new_event_loop()
152
+ asyncio.set_event_loop(loop)
153
+ tools_llm, tool_map = loop.run_until_complete(connect_mcp(path))
154
+ if tools_llm:
155
+ break
156
+
157
+ # Ensure system message
158
+ if not messages or messages[0].get("role") != "system":
159
+ sys_msg = get_system_message(npc) if npc else "You are an AI assistant with access to tools."
160
+ if tools_llm:
161
+ sys_msg += f"\n\nYou have access to these tools: {', '.join(t['function']['name'] for t in tools_llm)}"
162
+ messages.insert(0, {"role": "system", "content": sys_msg})
163
+
164
+ # Handle initial command if provided (one-shot mode)
165
+ if initial_command:
166
+ resp = get_llm_response(
167
+ initial_command,
168
+ model=model,
169
+ provider=provider,
170
+ messages=messages,
171
+ tools=tools_llm if tools_llm else None,
172
+ tool_map=tool_map if tool_map else None,
173
+ auto_process_tool_calls=True,
174
+ npc=npc
175
+ )
176
+ messages = resp.get('messages', messages)
177
+ render_markdown(str(resp.get('response', '')))
178
+ context['output'] = resp.get('response', 'Done.')
179
+ context['messages'] = messages
180
+ # Don't enter REPL for one-shot
181
+ exit()
182
+
183
+ # REPL loop
184
+ while True:
185
+ try:
186
+ prompt_str = f"{npc_name}:corca> "
187
+ user_input = input(prompt_str).strip()
188
+
189
+ if not user_input:
190
+ continue
191
+
192
+ if user_input.lower() == "/cq":
193
+ print("Exiting corca mode.")
194
+ break
195
+
196
+ # Handle /tools to list available tools
197
+ if user_input.lower() == "/tools":
198
+ if tools_llm:
199
+ print(colored("Available MCP tools:", "cyan"))
200
+ for t in tools_llm:
201
+ print(f" - {t['function']['name']}: {t['function'].get('description', '')[:60]}")
202
+ else:
203
+ print(colored("No MCP tools connected.", "yellow"))
204
+ continue
205
+
206
+ # Handle /connect to connect to new MCP server
207
+ if user_input.startswith("/connect "):
208
+ new_path = user_input[9:].strip()
209
+ try:
210
+ loop = asyncio.get_event_loop()
211
+ except RuntimeError:
212
+ loop = asyncio.new_event_loop()
213
+ asyncio.set_event_loop(loop)
214
+ tools_llm, tool_map = loop.run_until_complete(connect_mcp(new_path))
215
+ continue
216
+
217
+ # Get LLM response with tools
218
+ resp = get_llm_response(
219
+ user_input,
220
+ model=model,
221
+ provider=provider,
222
+ messages=messages,
223
+ tools=tools_llm if tools_llm else None,
224
+ tool_map=tool_map if tool_map else None,
225
+ auto_process_tool_calls=True,
226
+ stream=False, # Tool calls don't work well with streaming
227
+ npc=npc
228
+ )
229
+
230
+ messages = resp.get('messages', messages)
231
+ response_text = resp.get('response', '')
232
+ render_markdown(str(response_text))
233
+
234
+ # Track usage
235
+ if 'usage' in resp and npc and hasattr(npc, 'shared_context'):
236
+ usage = resp['usage']
237
+ npc.shared_context['session_input_tokens'] += usage.get('input_tokens', 0)
238
+ npc.shared_context['session_output_tokens'] += usage.get('output_tokens', 0)
239
+ npc.shared_context['turn_count'] += 1
240
+
241
+ except KeyboardInterrupt:
242
+ print("\nUse '/cq' to exit or continue.")
243
+ continue
244
+ except EOFError:
245
+ print("\nExiting corca mode.")
246
+ break
247
+
248
+ context['output'] = "Exited corca mode."
249
+ context['messages'] = messages
@@ -0,0 +1,317 @@
1
+ jinx_name: guac
2
+ description: Python data analysis mode - execute Python code with persistent locals, auto-load files
3
+ inputs:
4
+ - model: null
5
+ - provider: null
6
+ - plots_dir: null
7
+
8
+ steps:
9
+ - name: guac_repl
10
+ engine: python
11
+ code: |
12
+ import os
13
+ import sys
14
+ import io
15
+ import re
16
+ import traceback
17
+ from pathlib import Path
18
+ from datetime import datetime
19
+ from termcolor import colored
20
+
21
+ import numpy as np
22
+ import pandas as pd
23
+ import matplotlib.pyplot as plt
24
+
25
+ from npcpy.llm_funcs import get_llm_response
26
+ from npcpy.npc_sysenv import render_markdown, get_system_message
27
+
28
+ npc = context.get('npc')
29
+ team = context.get('team')
30
+ messages = context.get('messages', [])
31
+ plots_dir = context.get('plots_dir') or os.path.expanduser("~/.npcsh/plots")
32
+
33
+ model = context.get('model') or (npc.model if npc else None)
34
+ provider = context.get('provider') or (npc.provider if npc else None)
35
+
36
+ # Use shared_context for persistent Python locals
37
+ shared_ctx = npc.shared_context if npc and hasattr(npc, 'shared_context') else {}
38
+ if 'locals' not in shared_ctx:
39
+ shared_ctx['locals'] = {}
40
+
41
+ # Initialize locals with useful imports
42
+ guac_locals = shared_ctx['locals']
43
+ guac_locals.update({
44
+ 'np': np,
45
+ 'pd': pd,
46
+ 'plt': plt,
47
+ 'Path': Path,
48
+ 'os': os,
49
+ })
50
+
51
+ # Also store dataframes reference
52
+ if 'dataframes' not in shared_ctx:
53
+ shared_ctx['dataframes'] = {}
54
+ guac_locals['dataframes'] = shared_ctx['dataframes']
55
+
56
+ os.makedirs(plots_dir, exist_ok=True)
57
+
58
+ print("""
59
+ ██████╗ ██╗ ██╗ █████╗ ██████╗
60
+ ██╔════╝ ██║ ██║██╔══██╗██╔════╝
61
+ ██║ ███╗██║ ██║███████║██║
62
+ ██║ ██║██║ ██║██╔══██║██║
63
+ ╚██████╔╝╚██████╔╝██║ ██║╚██████╗
64
+ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝
65
+ """)
66
+
67
+ npc_name = npc.name if npc else "guac"
68
+ print(f"Entering guac mode (NPC: {npc_name}). Type '/gq' to exit.")
69
+ print(" - Type Python code directly to execute")
70
+ print(" - Type natural language to get code suggestions")
71
+ print(" - Drop file paths to auto-load data")
72
+ print(f" - Plots saved to: {plots_dir}")
73
+
74
+ def is_python_code(text):
75
+ """Check if text looks like Python code"""
76
+ text = text.strip()
77
+ if not text:
78
+ return False
79
+ # Common Python patterns
80
+ if re.match(r'^(import |from |def |class |if |for |while |with |try:|@|#)', text):
81
+ return True
82
+ if re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*\s*=', text):
83
+ return True
84
+ if re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*\s*\(', text):
85
+ return True
86
+ if re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*\s*\[', text):
87
+ return True
88
+ if text.startswith('print(') or text.startswith('plt.'):
89
+ return True
90
+ try:
91
+ compile(text, "<input>", "exec")
92
+ return True
93
+ except SyntaxError:
94
+ return False
95
+
96
+ def execute_python(code_str, locals_dict):
97
+ """Execute Python code and return output"""
98
+ output_capture = io.StringIO()
99
+ old_stdout, old_stderr = sys.stdout, sys.stderr
100
+
101
+ try:
102
+ sys.stdout = output_capture
103
+ sys.stderr = output_capture
104
+
105
+ # Try as expression first (for REPL-like behavior)
106
+ if '\n' not in code_str.strip():
107
+ try:
108
+ result = eval(compile(code_str, "<input>", "eval"), locals_dict)
109
+ if result is not None:
110
+ print(repr(result))
111
+ return output_capture.getvalue().strip()
112
+ except SyntaxError:
113
+ pass
114
+
115
+ # Execute as statements
116
+ exec(compile(code_str, "<input>", "exec"), locals_dict)
117
+ return output_capture.getvalue().strip()
118
+
119
+ except Exception:
120
+ traceback.print_exc(file=output_capture)
121
+ return output_capture.getvalue().strip()
122
+ finally:
123
+ sys.stdout, sys.stderr = old_stdout, old_stderr
124
+
125
+ def auto_load_file(file_path, locals_dict):
126
+ """Auto-load a file into locals based on extension"""
127
+ path = Path(file_path).expanduser()
128
+ if not path.exists():
129
+ return None
130
+
131
+ ext = path.suffix.lower()
132
+ var_name = f"data_{datetime.now().strftime('%H%M%S')}"
133
+
134
+ try:
135
+ if ext == '.csv':
136
+ df = pd.read_csv(path)
137
+ locals_dict[var_name] = df
138
+ shared_ctx['dataframes'][var_name] = df
139
+ return f"Loaded CSV as '{var_name}': {len(df)} rows, {len(df.columns)} columns\nColumns: {list(df.columns)}"
140
+
141
+ elif ext in ['.xlsx', '.xls']:
142
+ df = pd.read_excel(path)
143
+ locals_dict[var_name] = df
144
+ shared_ctx['dataframes'][var_name] = df
145
+ return f"Loaded Excel as '{var_name}': {len(df)} rows, {len(df.columns)} columns"
146
+
147
+ elif ext == '.json':
148
+ import json
149
+ with open(path) as f:
150
+ data = json.load(f)
151
+ locals_dict[var_name] = data
152
+ return f"Loaded JSON as '{var_name}': {type(data).__name__}"
153
+
154
+ elif ext in ['.png', '.jpg', '.jpeg', '.gif']:
155
+ from PIL import Image
156
+ img = Image.open(path)
157
+ arr = np.array(img)
158
+ locals_dict[f"{var_name}_img"] = img
159
+ locals_dict[f"{var_name}_arr"] = arr
160
+ return f"Loaded image as '{var_name}_img' and '{var_name}_arr': {img.size}"
161
+
162
+ elif ext == '.npy':
163
+ arr = np.load(path)
164
+ locals_dict[var_name] = arr
165
+ return f"Loaded numpy array as '{var_name}': shape {arr.shape}"
166
+
167
+ elif ext in ['.txt', '.md']:
168
+ with open(path) as f:
169
+ text = f.read()
170
+ locals_dict[var_name] = text
171
+ return f"Loaded text as '{var_name}': {len(text)} chars"
172
+
173
+ else:
174
+ with open(path, 'rb') as f:
175
+ data = f.read()
176
+ locals_dict[var_name] = data
177
+ return f"Loaded binary as '{var_name}': {len(data)} bytes"
178
+
179
+ except Exception as e:
180
+ return f"Error loading {path}: {e}"
181
+
182
+ def save_current_plot():
183
+ """Save current matplotlib figure if any"""
184
+ if plt.get_fignums():
185
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
186
+ plot_path = os.path.join(plots_dir, f"plot_{timestamp}.png")
187
+ plt.savefig(plot_path, dpi=150, bbox_inches='tight')
188
+ plt.close()
189
+ return plot_path
190
+ return None
191
+
192
+ # Ensure system message for LLM help
193
+ if not messages or messages[0].get("role") != "system":
194
+ sys_msg = """You are a Python data analysis assistant. When the user asks questions:
195
+ 1. Generate clean, executable Python code
196
+ 2. Use pandas, numpy, matplotlib as needed
197
+ 3. Reference variables already in the user's session
198
+ 4. Keep code concise and focused"""
199
+ messages.insert(0, {"role": "system", "content": sys_msg})
200
+
201
+ # REPL loop
202
+ while True:
203
+ try:
204
+ prompt_str = f"{npc_name}:guac> "
205
+ user_input = input(prompt_str).strip()
206
+
207
+ if not user_input:
208
+ continue
209
+
210
+ if user_input.lower() == "/gq":
211
+ print("Exiting guac mode.")
212
+ break
213
+
214
+ # /vars - show current variables
215
+ if user_input.lower() == "/vars":
216
+ print(colored("Current variables:", "cyan"))
217
+ for k, v in guac_locals.items():
218
+ if not k.startswith('_') and k not in ['np', 'pd', 'plt', 'Path', 'os', 'dataframes']:
219
+ vtype = type(v).__name__
220
+ if isinstance(v, pd.DataFrame):
221
+ print(f" {k}: DataFrame ({len(v)} rows)")
222
+ elif isinstance(v, np.ndarray):
223
+ print(f" {k}: ndarray {v.shape}")
224
+ else:
225
+ print(f" {k}: {vtype}")
226
+ continue
227
+
228
+ # /clear - clear variables
229
+ if user_input.lower() == "/clear":
230
+ keep = {'np', 'pd', 'plt', 'Path', 'os', 'dataframes'}
231
+ guac_locals.clear()
232
+ guac_locals.update({k: v for k, v in [('np', np), ('pd', pd), ('plt', plt), ('Path', Path), ('os', os), ('dataframes', shared_ctx['dataframes'])]})
233
+ print(colored("Variables cleared.", "yellow"))
234
+ continue
235
+
236
+ # Check if it's a file path (drag & drop)
237
+ potential_path = user_input.strip("'\"")
238
+ if os.path.exists(os.path.expanduser(potential_path)):
239
+ result = auto_load_file(potential_path, guac_locals)
240
+ if result:
241
+ print(colored(result, "green"))
242
+ continue
243
+
244
+ # Check if it's Python code
245
+ if is_python_code(user_input):
246
+ output = execute_python(user_input, guac_locals)
247
+ if output:
248
+ print(output)
249
+
250
+ # Save any plots
251
+ plot_path = save_current_plot()
252
+ if plot_path:
253
+ print(colored(f"Plot saved: {plot_path}", "green"))
254
+ continue
255
+
256
+ # Natural language - ask LLM for code
257
+ # Include current variables in context
258
+ var_context = "Current variables:\n"
259
+ for k, v in guac_locals.items():
260
+ if not k.startswith('_') and k not in ['np', 'pd', 'plt', 'Path', 'os', 'dataframes']:
261
+ if isinstance(v, pd.DataFrame):
262
+ var_context += f" {k}: DataFrame with columns {list(v.columns)}\n"
263
+ elif isinstance(v, np.ndarray):
264
+ var_context += f" {k}: ndarray shape {v.shape}\n"
265
+ else:
266
+ var_context += f" {k}: {type(v).__name__}\n"
267
+
268
+ prompt = f"{var_context}\nUser request: {user_input}\n\nGenerate Python code to accomplish this. Return ONLY the code, no explanation."
269
+
270
+ resp = get_llm_response(
271
+ prompt,
272
+ model=model,
273
+ provider=provider,
274
+ messages=messages,
275
+ npc=npc
276
+ )
277
+
278
+ messages = resp.get('messages', messages)
279
+ code_response = str(resp.get('response', ''))
280
+
281
+ # Extract code from response (strip markdown if present)
282
+ code = code_response
283
+ if '```python' in code:
284
+ code = code.split('```python')[1].split('```')[0]
285
+ elif '```' in code:
286
+ code = code.split('```')[1].split('```')[0]
287
+ code = code.strip()
288
+
289
+ if code:
290
+ print(colored("Generated code:", "cyan"))
291
+ print(code)
292
+ confirm = input("Execute? [Y/n/e(dit)]: ").strip().lower()
293
+
294
+ if confirm in ['', 'y', 'yes']:
295
+ output = execute_python(code, guac_locals)
296
+ if output:
297
+ print(output)
298
+ plot_path = save_current_plot()
299
+ if plot_path:
300
+ print(colored(f"Plot saved: {plot_path}", "green"))
301
+
302
+ elif confirm == 'e':
303
+ edited = input("Enter modified code: ").strip()
304
+ if edited:
305
+ output = execute_python(edited, guac_locals)
306
+ if output:
307
+ print(output)
308
+
309
+ except KeyboardInterrupt:
310
+ print("\nUse '/gq' to exit or continue.")
311
+ continue
312
+ except EOFError:
313
+ print("\nExiting guac mode.")
314
+ break
315
+
316
+ context['output'] = "Exited guac mode."
317
+ context['messages'] = messages