System Administrator commited on
Commit
f79de19
Β·
1 Parent(s): 2088aa3

Add Docker Space files

Browse files
Files changed (4) hide show
  1. Dockerfile +33 -0
  2. init_db.py +50 -0
  3. requirements.txt +5 -0
  4. server.py +1698 -0
Dockerfile ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Minimal Python base
2
+ FROM python:3.10-slim
3
+
4
+ # System deps (git for HF caching symlinks, etc.)
5
+ RUN apt-get update && apt-get install -y --no-install-recommends git \
6
+ && rm -rf /var/lib/apt/lists/*
7
+
8
+ # Non-root user + app and data dirs
9
+ RUN useradd -m -u 1000 user && mkdir -p /app /data && chown -R user:user /app /data
10
+ USER user
11
+ ENV PATH="/home/user/.local/bin:${PATH}"
12
+
13
+ # Persist HF cache & your DB between restarts
14
+ ENV HF_HOME=/data/.cache/huggingface \
15
+ API_DB_PATH=/data/api_keys.sqlite3 \
16
+ DEFAULT_API_KEY=sk-1234 \
17
+ MODEL_ID=TinyLlama/TinyLlama-1.1B-Chat-v1.0 \
18
+ PORT=7860
19
+
20
+ WORKDIR /app
21
+
22
+ # Python deps
23
+ COPY --chown=user:user requirements.txt .
24
+ RUN pip install --no-cache-dir --upgrade pip \
25
+ && pip install --no-cache-dir --extra-index-url https://download.pytorch.org/whl/cpu torch \
26
+ && pip install --no-cache-dir -r requirements.txt
27
+
28
+ # App code
29
+ COPY --chown=user:user . /app
30
+
31
+ # Create (idempotent) DB and start the server every boot
32
+ CMD python init_db.py && python server.py
33
+
init_db.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # init_db.py
2
+ import os, sqlite3
3
+ from datetime import datetime
4
+
5
+ # DB location (can be overridden by env)
6
+ DB_PATH = os.environ.get("API_DB_PATH", "/data/api_keys.sqlite3")
7
+
8
+ # Seed values
9
+ DEFAULT_KEY = os.environ.get("DEFAULT_API_KEY", "sk-1234") # can override via env
10
+ BOOTSTRAP_KEY = "sk-bootstrap-1234" # hard-coded as requested
11
+
12
+ os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
13
+
14
+ with sqlite3.connect(DB_PATH) as conn:
15
+ cur = conn.cursor()
16
+ cur.execute("""
17
+ CREATE TABLE IF NOT EXISTS api_keys(
18
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
19
+ api_key TEXT UNIQUE,
20
+ label TEXT,
21
+ created_at TEXT NOT NULL,
22
+ last_used TEXT,
23
+ active INTEGER NOT NULL DEFAULT 1
24
+ )
25
+ """)
26
+
27
+ now = datetime.utcnow().isoformat(timespec="seconds")
28
+
29
+ def upsert(key: str, label: str):
30
+ key = (key or "").strip()
31
+ if not key:
32
+ return
33
+ # Insert full row; on conflict, refresh label/last_used/active
34
+ cur.execute(
35
+ """
36
+ INSERT INTO api_keys(api_key, label, created_at, last_used, active)
37
+ VALUES (?, ?, ?, ?, 1)
38
+ ON CONFLICT(api_key) DO UPDATE SET
39
+ label = excluded.label,
40
+ last_used = excluded.last_used,
41
+ active = 1
42
+ """,
43
+ (key, label, now, now),
44
+ )
45
+
46
+ upsert(DEFAULT_KEY, "default")
47
+ upsert(BOOTSTRAP_KEY, "bootstrap")
48
+
49
+ conn.commit()
50
+
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ flask>=3.0
2
+ huggingface_hub>=0.24.0
3
+ transformers>=4.42.0
4
+ accelerate>=0.30.0
5
+
server.py ADDED
@@ -0,0 +1,1698 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Llama 3 CLI-Agent Server
4
+ ────────────────────────
5
+ Gemini-CLI style planner + executor:
6
+ β€’ plan with Meta-Llama-3-8B-Instruct
7
+ β€’ steps: shell, read_file, write_file, edit_file, append_file, list_dir, python, respond
8
+ β€’ robust JSON extraction (balanced braces) to avoid parse failures
9
+ """
10
+
11
+ from flask import Flask, request, jsonify
12
+ from huggingface_hub import snapshot_download
13
+ from transformers import AutoTokenizer, AutoModelForCausalLM, pipeline
14
+ import subprocess, os, json, traceback, io, contextlib
15
+ from pathlib import Path
16
+ import re
17
+ import os
18
+ import time
19
+ import sqlite3
20
+ from datetime import datetime
21
+ from functools import wraps
22
+ from flask import g
23
+ import platform
24
+ import shutil
25
+ import shlex
26
+ import torch
27
+
28
+ API_DB_PATH = os.environ.get("API_DB_PATH", "./api_keys.sqlite3")
29
+
30
+ MODEL_ID = os.environ.get("MODEL_ID", "TinyLlama/TinyLlama-1.1B-Chat-v1.0")
31
+
32
+ # ──────────────────────────────────────────────
33
+ # 3) Flask app & actions executor
34
+ # ──────────────────────────────────────────────
35
+ app = Flask(__name__)
36
+
37
+ SERVER_OS = platform.system().lower()
38
+ ALLOW_AUTO_INSTALL = os.environ.get("ALLOW_AUTO_INSTALL", "0") == "1"
39
+
40
+ def get_db():
41
+ if "db" not in g:
42
+ g.db = sqlite3.connect(API_DB_PATH, check_same_thread=False)
43
+ g.db.row_factory = sqlite3.Row
44
+ return g.db
45
+
46
+ @app.teardown_appcontext
47
+ def close_db(exc):
48
+ db = g.pop("db", None)
49
+ if db:
50
+ db.close()
51
+
52
+ def init_db():
53
+ db = get_db()
54
+ db.execute("""
55
+ CREATE TABLE IF NOT EXISTS api_keys(
56
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
57
+ api_key TEXT UNIQUE, -- raw key stored directly
58
+ label TEXT,
59
+ created_at TEXT NOT NULL,
60
+ last_used TEXT,
61
+ active INTEGER NOT NULL DEFAULT 1
62
+ )
63
+ """)
64
+ db.commit()
65
+
66
+ def _bearer_or_header_key() -> str | None:
67
+ auth = request.headers.get("Authorization", "")
68
+ if auth.startswith("Bearer "):
69
+ return auth.split(" ", 1)[1].strip()
70
+ xk = request.headers.get("X-API-Key")
71
+ return xk.strip() if xk else None
72
+
73
+ def validate_api_key() -> dict | None:
74
+ key = _bearer_or_header_key()
75
+ if not key:
76
+ return None
77
+ db = get_db()
78
+ row = db.execute(
79
+ "SELECT id, active FROM api_keys WHERE api_key=?",
80
+ (key,)
81
+ ).fetchone()
82
+ if not row or row["active"] != 1:
83
+ return None
84
+ # Update last_used
85
+ db.execute(
86
+ "UPDATE api_keys SET last_used=? WHERE id=?",
87
+ (datetime.utcnow().isoformat(timespec='seconds'), row["id"])
88
+ )
89
+ db.commit()
90
+ return dict(row)
91
+
92
+ def require_api_key(fn):
93
+ @wraps(fn)
94
+ def _wrap(*args, **kwargs):
95
+ ok = validate_api_key()
96
+ if not ok:
97
+ return jsonify({"error": "Unauthorized"}), 401
98
+ return fn(*args, **kwargs)
99
+ return _wrap
100
+
101
+ # ──────────────────────────────────────────────
102
+ # 0) Helpers
103
+ # ──────────────────────────────────────────────
104
+ def extract_first_json_object(text: str) -> dict:
105
+ """
106
+ Return the first valid top-level JSON object in `text` by scanning for balanced braces.
107
+ Raises ValueError if none found.
108
+ """
109
+ start = text.find("{")
110
+ if start < 0:
111
+ raise ValueError("no '{' found")
112
+
113
+ depth = 0
114
+ in_string = False
115
+ escape = False
116
+ for i in range(start, len(text)):
117
+ ch = text[i]
118
+ if in_string:
119
+ if escape:
120
+ escape = False
121
+ elif ch == "\\":
122
+ escape = True
123
+ elif ch == '"':
124
+ in_string = False
125
+ else:
126
+ if ch == '"':
127
+ in_string = True
128
+ elif ch == "{":
129
+ depth += 1
130
+ elif ch == "}":
131
+ depth -= 1
132
+ if depth == 0:
133
+ candidate = text[start : i + 1]
134
+ return json.loads(candidate)
135
+ raise ValueError("no balanced JSON object found")
136
+
137
+ def safe_exec_python(code):
138
+ """Run arbitrary python code in isolation and capture stdout/stderr tracebacks."""
139
+ buf = io.StringIO()
140
+ with contextlib.redirect_stdout(buf):
141
+ try:
142
+ exec(code, {"__name__": "__main__"})
143
+ except Exception:
144
+ traceback.print_exc()
145
+ return buf.getvalue()
146
+
147
+ # ──────────────────────────────────────────────
148
+ # 1) Model Loader β€” robust, self-healing
149
+ # ──────────────────────────────────────────────
150
+ def load_llm(model_id: str = MODEL_ID):
151
+ local_dir = Path("./tinyllama_1_1b_chat").resolve()
152
+
153
+ def have_min_tok(p: Path) -> bool:
154
+ return (p / "tokenizer.json").exists() or (p / "tokenizer.model").exists()
155
+
156
+ if not local_dir.exists() or not have_min_tok(local_dir):
157
+ print(f"[+] Downloading {model_id} into {local_dir} …")
158
+ snapshot_download(
159
+ repo_id=model_id,
160
+ local_dir=str(local_dir),
161
+ local_dir_use_symlinks=False,
162
+ revision="main",
163
+ )
164
+
165
+ print(f"[+] Loading TinyLlama from {local_dir} (CPU)")
166
+ tokenizer = AutoTokenizer.from_pretrained(
167
+ str(local_dir),
168
+ use_fast=True,
169
+ local_files_only=True,
170
+ trust_remote_code=True,
171
+ )
172
+ if tokenizer.pad_token is None:
173
+ tokenizer.pad_token = tokenizer.eos_token
174
+
175
+ model = AutoModelForCausalLM.from_pretrained(
176
+ str(local_dir),
177
+ device_map="cpu", # ← force CPU
178
+ torch_dtype=torch.float32, # ← CPU-friendly dtype
179
+ low_cpu_mem_usage=True,
180
+ local_files_only=True,
181
+ trust_remote_code=True,
182
+ )
183
+
184
+ pipe = pipeline(
185
+ task="text-generation",
186
+ model=model,
187
+ tokenizer=tokenizer,
188
+ max_new_tokens=256, # keep small for free CPU
189
+ do_sample=False,
190
+ return_full_text=False,
191
+ pad_token_id=tokenizer.pad_token_id,
192
+ eos_token_id=tokenizer.eos_token_id,
193
+ )
194
+ return pipe
195
+
196
+
197
+ def llm_chat(pipe, system_prompt: str, user_prompt: str) -> str:
198
+ tok = pipe.tokenizer
199
+ mdl = pipe.model
200
+ messages = [
201
+ {"role": "system", "content": system_prompt},
202
+ {"role": "user", "content": user_prompt},
203
+ ]
204
+ # build chat prompt with special tokens
205
+ input_ids = tok.apply_chat_template(
206
+ messages,
207
+ tokenize=True,
208
+ add_generation_prompt=True,
209
+ return_tensors="pt",
210
+ ).to(mdl.device)
211
+
212
+ outputs = mdl.generate(
213
+ input_ids=input_ids,
214
+ max_new_tokens=512,
215
+ do_sample=False,
216
+ eos_token_id=tok.eos_token_id,
217
+ pad_token_id=tok.pad_token_id,
218
+ )
219
+ # Only the generated continuation
220
+ gen_ids = outputs[0][input_ids.shape[-1]:]
221
+ text = tok.decode(gen_ids, skip_special_tokens=True)
222
+ return text
223
+
224
+ def llm_generate_text(pipe, system_prompt: str, user_prompt: str, max_new_tokens: int = 1200) -> str:
225
+ tok = pipe.tokenizer
226
+ mdl = pipe.model
227
+ messages = [
228
+ {"role": "system", "content": system_prompt},
229
+ {"role": "user", "content": user_prompt},
230
+ ]
231
+ input_ids = tok.apply_chat_template(
232
+ messages,
233
+ tokenize=True,
234
+ add_generation_prompt=True,
235
+ return_tensors="pt",
236
+ ).to(mdl.device)
237
+
238
+ outputs = mdl.generate(
239
+ input_ids=input_ids,
240
+ max_new_tokens=max_new_tokens,
241
+ do_sample=True, # allow creativity for content
242
+ temperature=0.7,
243
+ top_p=0.95,
244
+ eos_token_id=tok.eos_token_id,
245
+ pad_token_id=tok.pad_token_id,
246
+ )
247
+ gen_ids = outputs[0][input_ids.shape[-1]:]
248
+ return tok.decode(gen_ids, skip_special_tokens=True)
249
+
250
+ # --- Actionability helpers ---
251
+ # ACTIONABLE set
252
+ ACTIONABLE = {
253
+ "shell","read_file","write_file","edit_file","append_file",
254
+ "list_dir","python","generate_file","mkdirs","generate_tree","generate_large_file",
255
+ "rewrite_file","fs" # ← new
256
+ }
257
+
258
+ def llm_generate_text_exact(pipe, system_prompt: str, user_prompt: str, max_new_tokens: int = 1200) -> str:
259
+ tok = pipe.tokenizer
260
+ mdl = pipe.model
261
+ messages = [{"role": "system", "content": system_prompt},
262
+ {"role": "user", "content": user_prompt}]
263
+ input_ids = tok.apply_chat_template(messages, tokenize=True, add_generation_prompt=True, return_tensors="pt").to(mdl.device)
264
+ outputs = mdl.generate(
265
+ input_ids=input_ids,
266
+ max_new_tokens=max_new_tokens,
267
+ do_sample=False, # ← deterministic
268
+ temperature=0.0,
269
+ top_p=1.0,
270
+ eos_token_id=tok.eos_token_id,
271
+ pad_token_id=tok.pad_token_id,
272
+ )
273
+ gen_ids = outputs[0][input_ids.shape[-1]:]
274
+ return tok.decode(gen_ids, skip_special_tokens=True)
275
+
276
+ _CODE_BLOCK_RE = re.compile(r"```[a-zA-Z0-9_-]*\n(.*?)```", re.DOTALL)
277
+
278
+ def _extract_first_code_block(s: str) -> str:
279
+ m = _CODE_BLOCK_RE.search(s)
280
+ return (m.group(1) if m else s)
281
+
282
+ def _sanitize_generated_content(path: str | None, fmt: str, text: str) -> str:
283
+ s = (text or "").replace("\r\n", "\n").strip()
284
+
285
+ # Strip common lead-ins & fences
286
+ s = re.sub(r"^\s*(here\s+is.*?:|here'?s.*?:)\s*\n", "", s, flags=re.I)
287
+ s = _extract_first_code_block(s)
288
+ s = s.replace("```", "").strip()
289
+
290
+ name = (os.path.basename(path) if path else "").lower()
291
+
292
+ # requirements.txt β†’ keep only valid requirement lines
293
+ if name == "requirements.txt":
294
+ lines = []
295
+ for line in s.splitlines():
296
+ t = line.strip()
297
+ if not t or t.startswith("#"):
298
+ continue
299
+ if re.match(r"^[A-Za-z0-9_.-]+(\s*(?:[<>=!]=|===|==|~=)\s*[^#\s]+)?(\s*#.*)?$", t):
300
+ lines.append(t)
301
+ if not lines:
302
+ # hard fallback (good enough for the Flask scaffold)
303
+ return "flask\npytest\n"
304
+ return "\n".join(lines) + "\n"
305
+
306
+ return s
307
+
308
+ def _looks_like_literal_content(path: str | None, fmt: str, instruction: str) -> bool:
309
+ """True if user is giving us the final file body (not 'write ... about ...')."""
310
+ instr = (instruction or "").strip()
311
+ low = instr.lower()
312
+ has_verbs = re.search(r"\b(create|write|generate|explain|tutorial|guide|steps|add|include|document)\b", low)
313
+ codey = re.search(r"\b(def |class |from |import |if __name__ == .__main__|@app)\b", instr)
314
+ many_newlines = instr.count("\n") >= 1
315
+ return (many_newlines and not has_verbs) or bool(codey)
316
+
317
+
318
+ def _has_actionable(steps):
319
+ return any((s.get("type") or "").lower() in ACTIONABLE for s in (steps or []))
320
+
321
+ def _plan_create_file_from_prompt(prompt: str):
322
+ p = prompt.strip()
323
+
324
+ # Pattern A: "create a file NAME at /dir with/about/on CONTENT"
325
+ m = re.search(
326
+ r"(?:create|make|generate|write)\s+(?:an?\s+)?file\s+([A-Za-z0-9._-]+)"
327
+ r"\s+(?:in|at)\s+(/[\w/\.-]+)"
328
+ r"(?:\s+(?:with|containing|about|on)\s+(.+))?$",
329
+ p, re.I)
330
+ if m:
331
+ filename, dirpath, about = m.group(1), m.group(2), (m.group(3) or "").strip()
332
+ path = f"{dirpath.rstrip('/')}/{filename}"
333
+ instruction = about or "Create a very short factual note."
334
+ return {
335
+ "steps": [
336
+ {
337
+ "type": "generate_file",
338
+ "path": path,
339
+ "instruction": instruction, # e.g., "information on Amitabh Bachchan"
340
+ "format": "text",
341
+ "length": "short" # keep it concise, not an article
342
+ },
343
+ {
344
+ "type": "respond_llm",
345
+ "instruction": f"Confirm that '{path}' was created and summarize in one line what you wrote.",
346
+ "use_previous": False
347
+ }
348
+ ]
349
+ }
350
+
351
+ # Pattern B: "create a file NAME at /dir" (no content -> empty file)
352
+ m = re.search(
353
+ r"(?:create|make|generate|write)\s+(?:an?\s+)?file\s+([A-Za-z0-9._-]+)\s+(?:in|at)\s+(/[\w/\.-]+)\s*$",
354
+ p, re.I)
355
+ if m:
356
+ filename, dirpath = m.group(1), m.group(2)
357
+ path = f"{dirpath.rstrip('/')}/{filename}"
358
+ return {
359
+ "steps": [
360
+ {"type": "write_file", "path": path, "content": "", "mode": "w"},
361
+ {"type": "respond_llm", "instruction": f"Confirm creation of '{path}'.", "use_previous": False}
362
+ ]
363
+ }
364
+
365
+ return None
366
+
367
+ def _plan_edit_file_from_prompt(prompt: str):
368
+ """
369
+ Detect common 'edit/update/upgrade/modify/replace' intents on a specific file path,
370
+ optionally with a second path (e.g., an image to use), and produce a rewrite_file step.
371
+ """
372
+ s = prompt.strip()
373
+ # Any edit-like verb?
374
+ if not re.search(r"\b(edit|update|upgrade|modify|change|replace|append|insert|use|refactor)\b", s, re.I):
375
+ return None
376
+
377
+ # Target file path (absolute or relative like ./, ../), or bare filename.ext
378
+ match_paths = list(re.finditer(
379
+ r"((?:\./|\../|/)?[\w\-/\.]+?\.(?:html?|txt|md|json|py|js|css|ts|tsx|jsx|scss))",
380
+ s, re.I
381
+ ))
382
+ if match_paths:
383
+ # Prefer the longest match (so "./test/index.html" wins over "/test/index.html")
384
+ target_path = max(match_paths, key=lambda m: m.end()-m.start()).group(1)
385
+ else:
386
+ # Try a simple filename.ext
387
+ m_simple = re.search(r"\b([A-Za-z0-9._-]+\.(?:html?|txt|md|json|py|js|css|ts|tsx|jsx|scss))\b", s, re.I)
388
+ if not m_simple:
389
+ return None
390
+ target_path = m_simple.group(1)
391
+
392
+ # Optional second path (e.g., image)
393
+ img = re.search(r"(/[\w\-/\.]+\.(?:png|jpe?g|gif|svg|webp))", s, re.I)
394
+ instruction = prompt.strip()
395
+
396
+ # If both an HTML file and an image path are present, add a helpful relative-path hint.
397
+ if img and re.search(r"\.html?$", target_path, re.I):
398
+ img_path = img.group(1)
399
+ try:
400
+ rel = os.path.relpath(img_path, start=os.path.dirname(target_path))
401
+ except Exception:
402
+ rel = img_path
403
+ instruction += (
404
+ f"\n\nNote: Prefer referencing the image via the relative path '{rel}' "
405
+ f"(instead of an absolute file path) so it loads when opened locally."
406
+ )
407
+
408
+ steps = [
409
+ {"type": "rewrite_file", "path": target_path, "instruction": instruction, "length": "long"},
410
+ ]
411
+ want_show = re.search(r"\b(show|display|print|reveal|dump)\b", s, re.I)
412
+ if want_show:
413
+ steps.append({"type": "fs", "op": "read", "path": target_path})
414
+ steps.append({"type": "respond_llm",
415
+ "instruction": f"Show the final contents of '{target_path}'. If it's long, summarize sections and key changes.",
416
+ "use_previous": True})
417
+ else:
418
+ steps.append({"type": "respond_llm",
419
+ "instruction": f"Briefly confirm the update to '{target_path}' and how to open it.",
420
+ "use_previous": False})
421
+ return {"steps": steps}
422
+
423
+ _QA_PREFIX_RE = re.compile(r'(?:^|\n)\s*question:\s*(.+)\Z', re.IGNORECASE | re.DOTALL)
424
+
425
+ def _extract_question_from_instruction(instruction: str) -> str:
426
+ """
427
+ Pull the user question out of an instruction blob like:
428
+ 'Answer clearly ... Do NOT repeat the question.\\n\\nQuestion: Who is Ada Lovelace?'
429
+ Falls back to the instruction text if no Question: header is present.
430
+ Also strips obvious meta preambles like 'Answer clearly...' lines.
431
+ """
432
+ instr = instruction or ""
433
+ m = _QA_PREFIX_RE.search(instr)
434
+ if m:
435
+ return m.group(1).strip()
436
+
437
+ # remove common meta lines the planner adds
438
+ cleaned = []
439
+ for line in instr.splitlines():
440
+ low = line.strip().lower()
441
+ if low.startswith(("answer", "instruction", "do not repeat", "don’t repeat", "do n't repeat")):
442
+ continue
443
+ cleaned.append(line)
444
+ q = "\n".join(cleaned).strip()
445
+ return q or instr.strip()
446
+
447
+
448
+ def _strip_meta_lines(ans: str) -> str:
449
+ """Remove any stray 'Question:'/'Instruction:'/'Answer:' prefixes the model might echo."""
450
+ lines = []
451
+ for ln in (ans or "").splitlines():
452
+ low = ln.strip().lower()
453
+ if low.startswith(("question:", "instruction:", "answer:")):
454
+ continue
455
+ lines.append(ln)
456
+ return "\n".join(lines).strip()
457
+
458
+ # ──────────────────────────────────────────────
459
+ # Planner tool catalog + schema (module-scope)
460
+ # ──────────────────────────────────────────────
461
+ TOOLS = """
462
+ TOOLS (choose as few as possible to satisfy the request):
463
+
464
+ 1) fs
465
+ - A generic filesystem tool.
466
+ - Fields:
467
+ {"type":"fs","op":"list|read|write|append|mkdir|remove|move|copy|exists|glob",
468
+ "path":"<abs or relative path>",
469
+ "content":"<text>", "to":"<dest path>", "pattern":"<glob pattern>"}
470
+ - Use cases:
471
+ β€’ "ls /path", "show/list contents of DIR" β†’ {"type":"fs","op":"list","path":"/path"}
472
+ β€’ "remove/delete file /a/b.txt" β†’ {"type":"fs","op":"remove","path":"/a/b.txt"}
473
+ β€’ "show /a/b.txt" / "cat file" β†’ {"type":"fs","op":"read","path":"/a/b.txt"}
474
+
475
+ 2) shell
476
+ - Run an OS command when no dedicated tool exists.
477
+ - Prefer fs over shell for file management.
478
+ - Use per-OS mapping via {"cmd": {"linux":"…","darwin":"…","windows":"…"}}.
479
+
480
+ 3) read_file / write_file / append_file / mkdirs / list_dir
481
+ - Legacy, still allowed; prefer fs unless the user explicitly asked for these.
482
+
483
+ 4) python
484
+ - For quick local computations or tiny scripts.
485
+
486
+ 5) generate_file / generate_tree / generate_large_file / rewrite_file
487
+ - For content/code generation and edits.
488
+
489
+ Always end with ONE summarize step:
490
+ {"type":"respond_llm","instruction":"Briefly confirm what happened or show the results.","use_previous":true}
491
+ """
492
+
493
+ PLANNER_SCHEMA = (
494
+ "You are a CLI automation planner that MUST return ONLY a single JSON object.\n"
495
+ "NO prose. NO markdown. JSON ONLY.\n\n"
496
+ + TOOLS +
497
+ "\nSchema:\n"
498
+ "{\n"
499
+ ' "steps": [ <one or more tool steps from TOOLS, and finally exactly one respond/respond_llm> ]\n'
500
+ "}\n"
501
+ "Rules:\n"
502
+ "- If the request requires inspecting or changing the system/files, you MUST use a tool step (not just respond).\n"
503
+ "- Prefer fs for file/directory operations.\n"
504
+ "- Keep steps minimal and directly useful.\n"
505
+ "- Include timeouts/cwd/env on shell only when needed.\n"
506
+ "- End with exactly one respond/respond_llm (use_previous=true when summarizing gathered output).\n"
507
+ "\nExamples:\n"
508
+ "USER: ls /tmp\n"
509
+ '{"steps":[{"type":"fs","op":"list","path":"/tmp"},{"type":"respond_llm","instruction":"Summarize directory contents.","use_previous":true}]}\n'
510
+ "USER: what are the contents of the directory /var/log\n"
511
+ '{"steps":[{"type":"fs","op":"list","path":"/var/log"},{"type":"respond_llm","instruction":"List entries clearly.","use_previous":true}]}\n'
512
+ "USER: remove file /Users/alex/test.html\n"
513
+ '{"steps":[{"type":"fs","op":"remove","path":"/Users/alex/test.html"},{"type":"respond_llm","instruction":"Confirm deletion.","use_previous":false}]}\n'
514
+ )
515
+
516
+ # ──────────────────────────────────────────────
517
+ # 2) Planning logic β€” strict JSON with schema + robust parse
518
+ # ──────────────────────────────────────────────
519
+ def plan_actions_from_prompt(model_pipe, prompt, context=""):
520
+ # Pre-parsed quick path: explicit "create file ..." phrasing
521
+
522
+ pre_edit = _plan_edit_file_from_prompt(prompt)
523
+ if pre_edit:
524
+ return pre_edit
525
+
526
+ pre = _plan_create_file_from_prompt(prompt)
527
+ if pre:
528
+ return pre
529
+
530
+ s = prompt.lower().strip()
531
+
532
+ # ── NEW: Heuristic for "create folder here + create file" (your case) ──
533
+ # examples: "create a folder named test here ... and create a test.html ..."
534
+ folder_re = re.search(
535
+ r"(?:create|make|mkdir)\s+(?:a\s+)?(?:folder|directory)\s+(?:named|called)?\s*([A-Za-z0-9._-]+)",
536
+ prompt, re.I
537
+ )
538
+ file_re = re.search(
539
+ r"(?:create|make|generate|write)\s+(?:an?\s+)?([A-Za-z0-9._-]+\.(?:html?|txt|md|json|py|js|css))",
540
+ prompt, re.I
541
+ )
542
+ # optional absolute base path like "in /tmp" or "at /var/www"
543
+ abs_base_re = re.search(r"\b(?:in|at)\s+(/[\w/\.-]+)", prompt, re.I)
544
+ # detect "here" wording
545
+ here_re = re.search(r"\b(here|in\s+(?:this|the)\s+directory|in\s+current\s+dir(?:ectory)?|in\s+\.)\b", s)
546
+
547
+ if folder_re or file_re:
548
+ folder = folder_re.group(1) if folder_re else None
549
+ filename = file_re.group(1) if file_re else None
550
+ base = abs_base_re.group(1).rstrip("/") if abs_base_re else "."
551
+
552
+ steps = []
553
+
554
+ # Create folder if requested (relative to base unless absolute provided above)
555
+ if folder:
556
+ folder_path = f"{base}/{folder}" if base != "." else f"./{folder}"
557
+ steps.append({"type": "mkdirs", "paths": [folder_path]})
558
+
559
+ # Create file if requested
560
+ # Create file if requested
561
+ if filename:
562
+ # Detect explicit file path in prompt (if present, respect it)
563
+ explicit_file_path = re.search(
564
+ r"(/[\w/\.-]+\.(?:html?|txt|md|json|py|js|css))", prompt, re.I
565
+ )
566
+
567
+ if explicit_file_path:
568
+ path = explicit_file_path.group(1)
569
+ elif folder:
570
+ # Default to placing the file inside the newly created folder
571
+ path = (f"{base}/{folder}/{filename}") if base != "." else f"./{folder}/{filename}"
572
+ else:
573
+ path = (f"{base}/{filename}") if base != "." else f"./{filename}"
574
+
575
+
576
+ # Build a helpful instruction from the prompt
577
+ wants_pics = bool(re.search(r"\b(pictures?|images?|gallery|photos?)\b", s))
578
+ fmt = "html" if filename.lower().endswith((".html", ".htm")) else "text"
579
+ length = "long" if fmt == "html" else "medium"
580
+
581
+ instruction = prompt.strip()
582
+ # If user asked for pictures and it's HTML, steer to a nice sample gallery
583
+ if fmt == "html" and wants_pics:
584
+ instruction = (
585
+ "Create a single-file, modern HTML5 page with embedded <style> CSS: "
586
+ "a clean header, hero section, and a responsive image grid (6–9 images). "
587
+ "Use web-safe fonts or a Google Fonts import, CSS grid/flex, subtle shadows, "
588
+ "and hover effects. Use external placeholder photos (e.g., Unsplash image URLs) "
589
+ "with descriptive alt text and loading='lazy'. No JS required."
590
+ )
591
+
592
+ steps.append({
593
+ "type": "generate_file",
594
+ "path": path,
595
+ "instruction": instruction,
596
+ "format": fmt,
597
+ "length": length
598
+ })
599
+
600
+ # Conclude with a short confirmation
601
+ steps.append({
602
+ "type": "respond_llm",
603
+ "instruction": "Confirm what was created and how to open the HTML in a browser.",
604
+ "use_previous": False
605
+ })
606
+ return {"steps": steps}
607
+
608
+ # ── Heuristic for β€œsave it at/to/in <path.ext>” or β€œβ€¦ at/in <path.ext>”
609
+ save_any = re.search(
610
+ r"\b(?:save\s+(?:it|this|the\s+\w+)?\s*)?(?:at|to|in)\s+((?:\./|\../|/)?[\w/\.\-]+?\.(?:txt|md|html?|json|py|js|css))",
611
+ prompt, re.I
612
+ )
613
+ if save_any:
614
+ path = save_any.group(1)
615
+ lower = path.lower()
616
+ if lower.endswith((".html",".htm")): fmt = "html"
617
+ elif lower.endswith(".md"): fmt = "markdown"
618
+ else: fmt = "text"
619
+ # honor "2000 words" or "2000-word"
620
+ wants_long = bool(re.search(r"\b(\d{3,4})\s*[- ]?\s*words?\b", prompt, re.I))
621
+ length = "long" if wants_long else "medium"
622
+ return {
623
+ "steps": [
624
+ {"type":"generate_file","path":path,"instruction":prompt.strip(),"format":fmt,"length":length},
625
+ {"type":"respond_llm","instruction":f"Confirm that '{path}' was written and how to open it.","use_previous":True}
626
+ ]
627
+ }
628
+
629
+
630
+ # ── Light intent: reverse DNS on IPs ──
631
+ ip_match = re.search(r"\b(\d{1,3}(?:\.\d{1,3}){3})\b", prompt)
632
+ if ip_match and re.search(r"\b(dns|dns\s*check|reverse\s*dns|ptr|rdns|hostname)\b", s):
633
+ ip = ip_match.group(1)
634
+ steps = [{
635
+ "type": "shell",
636
+ "cmd": {
637
+ "linux": f"dig -x {ip} +short",
638
+ "darwin": f"dig -x {ip} +short",
639
+ "windows": f"nslookup -type=PTR {ip}"
640
+ },
641
+ "requires": {"linux": ["dig"], "darwin": ["dig"]},
642
+ "timeout": 10
643
+ }]
644
+ if re.search(r"\b(whois|owner|asn|org|organisation|organization|provider)\b", s):
645
+ steps.append({
646
+ "type": "shell",
647
+ "cmd": {
648
+ "linux": f"whois {ip} | head -n 80",
649
+ "darwin": f"whois {ip} | head -n 80",
650
+ "windows": f"whois {ip}"
651
+ },
652
+ "env": {"PAGER":"cat","LESS":"-R"},
653
+ "requires": {"linux": ["whois"], "darwin": ["whois"], "windows": ["whois"]},
654
+ "timeout": 10
655
+ })
656
+ return {"steps": steps}
657
+
658
+ # Quick path: "node and npm versions"
659
+ if re.search(r"\bnode\b.*\bnpm\b.*\bversion", s) or re.search(r"\bversions?\b.*\bnode\b.*\bnpm\b", s):
660
+ return {"steps":[
661
+ {"type":"shell","cmd":{"linux":"node -v && npm -v","darwin":"node -v && npm -v","windows":"node -v & npm -v"}},
662
+ {"type":"respond_llm","instruction":"Report the exact Node.js and npm versions from the previous output (no guessing).","use_previous":True}
663
+ ]}
664
+
665
+ # Quick path: count non-comment entries in /etc/hosts
666
+ if ("/etc/hosts" in prompt) and re.search(r"\bnon[- ]?comment\b", s) and re.search(r"\bhow\s+many\b", s):
667
+ return {"steps":[
668
+ {"type":"python","code":
669
+ "count=0\n"
670
+ "with open('/etc/hosts') as f:\n"
671
+ " for line in f:\n"
672
+ " s=line.strip()\n"
673
+ " if s and not s.startswith('#'):\n"
674
+ " count+=1\n"
675
+ "print(count)\n"},
676
+ {"type":"respond_llm","instruction":"Tell me the number you just computed.","use_previous":True}
677
+ ]}
678
+
679
+ # Quick path: scaffold a minimal Flask app at <dir>
680
+ m_flask = re.search(r"\bscaffold\b.*\bflask\b.*?(?:at|in)\s+((?:\./|\../|/)?[\w/\.-]+)", prompt, re.I)
681
+ if m_flask:
682
+ base_dir = m_flask.group(1) if m_flask.group(1) else "./flask_demo"
683
+ return {"steps":[
684
+ {"type":"generate_tree","base":base_dir,"files":[
685
+ {"path":"requirements.txt","format":"text","length":"short","instruction":"flask\npytest\n"},
686
+ {"path":"app.py","format":"code:python","length":"medium","instruction":
687
+ "from flask import Flask\napp=Flask(__name__)\n@app.get('/')\n"
688
+ "def hello():\n return 'Hello, Flask!'\n\nif __name__=='__main__':\n"
689
+ " app.run(host='0.0.0.0',port=5000)"},
690
+ {"path":"tests/test_app.py","format":"code:python","length":"short","instruction":
691
+ "from app import app\n\ndef test_root():\n c=app.test_client(); r=c.get('/')\n assert r.status_code==200"},
692
+ {"path":"README.md","format":"markdown","length":"short","instruction":
693
+ "# Flask demo\n\n## Setup\n\n```\npython3 -m venv .venv\n. .venv/bin/activate\npip install -r requirements.txt\n```\n\n## Run\n```\nflask --app app run --host=0.0.0.0 --port=5000\n```\n"},
694
+ {"path":".gitignore","format":"text","length":"short","instruction":"__pycache__/\n.venv/\n"}
695
+ ]},
696
+ {"type":"shell","cmd":{
697
+ "linux":"python3 -m venv .venv && . .venv/bin/activate && pip install -r requirements.txt",
698
+ "darwin":"python3 -m venv .venv && . .venv/bin/activate && pip install -r requirements.txt",
699
+ "windows":"python -m venv .venv && .\\.venv\\Scripts\\pip install -r requirements.txt"
700
+ },"cwd":base_dir},
701
+ {"type":"shell","cmd":{
702
+ "linux":"FLASK_APP=app flask run --host=0.0.0.0 --port=5000",
703
+ "darwin":"flask --app app run --host=0.0.0.0 --port=5000",
704
+ "windows":"set FLASK_APP=app && flask run --host=0.0.0.0 --port=5000"
705
+ },"cwd":base_dir},
706
+ {"type":"respond_llm","instruction":"Confirm scaffold and how to run locally.","use_previous":True}
707
+ ]}
708
+
709
+ # Quick path: scaffold a Python CLI (pyproject + console_scripts)
710
+ m_cli = re.search(
711
+ r"\b(cli|command[- ]line)\b.*\b(pyproject\.toml|console[_-]?scripts|entry\s*point)\b.*?(?:at|in)\s+((?:\./|\../|/)?[\w/\.-]+)",
712
+ prompt, re.I
713
+ )
714
+ if m_cli:
715
+ base_dir = m_cli.group(3) if m_cli.group(3) else "./time_cli"
716
+ return {"steps": [
717
+ {"type": "generate_tree", "base": base_dir, "files": [
718
+ {"path": "pyproject.toml", "format": "text", "length": "short", "instruction":
719
+ "[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n\n"
720
+ "[project]\nname = \"time-cli\"\nversion = \"0.1.0\"\n"
721
+ "description = \"Prints local and UTC time\"\nreadme = \"README.md\"\n"
722
+ "requires-python = \">=3.8\"\ndependencies = []\n\n"
723
+ "[project.scripts]\n"
724
+ "time-cli = \"time_cli.cli:main\"\n\n"
725
+ "[tool.pytest.ini_options]\naddopts = \"-q\"\n"},
726
+ {"path": "src/time_cli/__init__.py", "format": "code:python", "length": "short", "instruction":
727
+ "__all__ = [\"__version__\"]\n__version__ = \"0.1.0\"\n"},
728
+ {"path": "src/time_cli/cli.py", "format": "code:python", "length": "short", "instruction":
729
+ "from datetime import datetime, timezone\n\n"
730
+ "def main() -> None:\n"
731
+ " local = datetime.now()\n"
732
+ " utc = datetime.now(timezone.utc)\n"
733
+ " print(f\"Local time: {local.strftime('%Y-%m-%d %H:%M:%S')}\")\n"
734
+ " print(f\"UTC time: {utc.strftime('%Y-%m-%d %H:%M:%S')}\")\n\n"
735
+ "if __name__ == \"__main__\":\n"
736
+ " main()\n"},
737
+ {"path": "tests/test_cli.py", "format": "code:python", "length": "short", "instruction":
738
+ "from io import StringIO\nimport contextlib\nfrom time_cli import cli\n\n"
739
+ "def test_output():\n"
740
+ " buf = StringIO()\n"
741
+ " with contextlib.redirect_stdout(buf):\n"
742
+ " cli.main()\n"
743
+ " out = buf.getvalue()\n"
744
+ " assert \"Local time:\" in out and \"UTC time:\" in out\n"},
745
+ {"path": "README.md", "format": "markdown", "length": "short", "instruction":
746
+ "# Time CLI\n\n"
747
+ "A tiny CLI that prints local and UTC time.\n\n"
748
+ "## Install & run\n\n"
749
+ "```bash\npython3 -m venv .venv\n. .venv/bin/activate\npip install -U pip\npip install -e .\npip install pytest\npytest\n"
750
+ "time-cli\n```\n"},
751
+ {"path": ".gitignore", "format": "text", "length": "short", "instruction":
752
+ "__pycache__/\n.venv/\n*.pyc\n*.pyo\n*.pytest_cache/\n"}
753
+ ]},
754
+ {"type": "shell", "cmd": {
755
+ "linux": "python3 -m venv .venv && . .venv/bin/activate && pip install -U pip && pip install -e . && pip install pytest",
756
+ "darwin": "python3 -m venv .venv && . .venv/bin/activate && pip install -U pip && pip install -e . && pip install pytest",
757
+ "windows": "python -m venv .venv && .\\.venv\\Scripts\\pip install -U pip && .\\.venv\\Scripts\\pip install -e . && .\\.venv\\Scripts\\pip install pytest"
758
+ }, "cwd": base_dir},
759
+ {"type": "shell", "cmd": {
760
+ "linux": "pytest",
761
+ "darwin": "pytest",
762
+ "windows": ".\\.venv\\Scripts\\pytest"
763
+ }, "cwd": base_dir},
764
+ {"type": "shell", "cmd": {
765
+ "linux": "time-cli",
766
+ "darwin": "time-cli",
767
+ "windows": ".\\.venv\\Scripts\\time-cli"
768
+ }, "cwd": base_dir},
769
+ {"type": "respond_llm", "instruction": "Confirm scaffold and how to run locally.", "use_previous": True}
770
+ ]}
771
+
772
+ # Quick path: "open browser and search ..." / "google '...'"
773
+ m_web = (re.search(r'\b(open|launch)\s+(?:a\s+)?browser.*?(?:search|google|bing|duckduckgo)?\s*(?:for|about)?\s*"([^"]+)"', prompt, re.I)
774
+ or re.search(r'\b(search|google|bing|duckduckgo)\b.*?"([^"]+)"', prompt, re.I))
775
+ if m_web:
776
+ from urllib.parse import quote_plus
777
+ query = m_web.group(2)
778
+ url = f"https://www.google.com/search?q={quote_plus(query)}"
779
+ return {"steps": [
780
+ {"type":"shell","cmd":{
781
+ "linux": f'xdg-open "{url}"',
782
+ "darwin": f'open "{url}"',
783
+ "windows": f'start "" "{url}"'
784
+ }},
785
+ {"type":"respond_llm",
786
+ "instruction": f"Tell the user their default browser was opened to a Google search for β€œ{query}”. If it didn’t open, provide the URL shown in context.",
787
+ "use_previous": False,
788
+ "context": url}
789
+ ]}
790
+
791
+
792
+ # App scaffold hint (nudges the model toward generate_tree + setup commands)
793
+ APP_HINT = re.search(
794
+ r"\b(flask|fastapi|django|react|next\.js|node|express|go\b|rust\b|java\b|spring|kotlin|swiftui|vue|svelte|angular)\b",
795
+ s
796
+ )
797
+ if APP_HINT:
798
+ context = (context or "") + (
799
+ "\n\nPLANNING_HINT: For apps, return generate_tree with a clean project layout, "
800
+ "plus shell steps to install deps and run dev server/tests."
801
+ )
802
+
803
+ # Normal LLM planning β€” always define SCHEMA
804
+ SCHEMA = (
805
+ "You are a CLI automation planner that MUST return ONLY a single JSON object.\n"
806
+ "NO prose. NO markdown. JSON ONLY.\n\n"
807
+ "Schema:\n"
808
+ "{\n"
809
+ ' "steps": [\n'
810
+ ' {"type":"respond","text":"<final answer text>"} |\n'
811
+ ' {"type":"respond_llm","instruction":"<what to write>", "use_previous":true, "context":"<optional extra>"} |\n'
812
+ ' {"type":"shell","cmd":"<command>","cwd":"<optional path>","timeout":<seconds>,"env":{"K":"V"},"requires":{"linux":["..."],"darwin":["..."],"windows":["..."]}} |\n'
813
+ ' {"type":"read_file","path":"<path>"} |\n'
814
+ ' {"type":"write_file","path":"<path>","content":"<text>","mode":"w|a"} |\n'
815
+ ' {"type":"edit_file","path":"<path>","content":"<patch or full text>"} |\n'
816
+ ' {"type":"append_file","path":"<path>","content":"<text>"} |\n'
817
+ ' {"type":"list_dir","path":"<path>"} |\n'
818
+ ' {"type":"python","code":"<python code>"} |\n'
819
+ ' {"type":"generate_file","path":"<path>","instruction":"<what to write>","format":"text|code:<lang>|markdown|html","length":"short|medium|long|xl"} |\n'
820
+ ' {"type":"mkdirs","paths":["<dir>", "..."]} |\n'
821
+ ' {"type":"generate_tree","base":"<dir>","files":[{"path":"<rel path>","instruction":"...","format":"text|code:<lang>|html|markdown","length":"short|medium|long|xl"}]} |\n'
822
+ ' {"type":"generate_large_file","path":"<path>","chunks":[{"instruction":"...","length":"short|medium|long|xl"}, "..."]}\n'
823
+ ' {"type":"rewrite_file","path":"<path>","instruction":"<how to change the file>","length":"short|medium|long|xl"} |\n'
824
+ " ]\n"
825
+ "}\n"
826
+ "Rules:\n"
827
+ "- The JSON MUST include a non-empty 'steps' array.\n"
828
+ "- For imperative requests (e.g., create/make/run/write), prefer executable steps over explanations.\n"
829
+ "- When reading/inspecting data, gather with read_file/shell/python, then ONE respond_llm(use_previous=true).\n"
830
+ "- Use 'respond' only when you include the actual final answer text (no placeholders).\n"
831
+ "- Prefer 'generate_tree' for apps/libraries: create a real multi-file project layout (modules/packages, config, tests).\n"
832
+ "- For big files, use 'generate_large_file' (or multiple append_file) to write in chunks.\n"
833
+ "- Use format 'code:<lang>' (e.g., code:python, code:javascript, code:go) for code files; no backticks.\n"
834
+ "- Add shell steps to set up and run the project (pip/npm/etc.).\n"
835
+ "- Keep steps minimal and directly useful.\n"
836
+ )
837
+
838
+ USER_BLOCK = f"USER_INSTRUCTION: {prompt}\nSERVER_OS: {SERVER_OS}\nCONTEXT: {context}\nRETURN JSON NOW:"
839
+ raw = llm_chat(model_pipe, PLANNER_SCHEMA, USER_BLOCK)
840
+ if os.getenv("AGENT_DEBUG") == "1":
841
+ print("\n=== RAW LLM OUTPUT (pass1) ===\n", raw, "\n==============================\n", flush=True)
842
+
843
+ try:
844
+ plan = extract_first_json_object(raw)
845
+ if not isinstance(plan, dict) or not isinstance(plan.get("steps", []), list) or len(plan["steps"]) == 0:
846
+ raise ValueError("empty or invalid 'steps'")
847
+
848
+ if not _has_actionable(plan.get("steps")):
849
+ h = _plan_create_file_from_prompt(prompt)
850
+ if h:
851
+ return h
852
+
853
+ e = _plan_edit_file_from_prompt(prompt) # ← NEW fallback for edit/upgrade requests
854
+ if e:
855
+ return e
856
+ return plan
857
+
858
+ except Exception:
859
+ STRICT = PLANNER_SCHEMA + "\nMUST include at least one executable step in 'steps' (not only 'respond')."
860
+ raw2 = llm_chat(model_pipe, STRICT, USER_BLOCK)
861
+ if os.getenv("AGENT_DEBUG") == "1":
862
+ print("\n=== RAW LLM OUTPUT (pass2) ===\n", raw2, "\n==============================\n", flush=True)
863
+ try:
864
+ plan2 = extract_first_json_object(raw2)
865
+ if isinstance(plan2, dict) and isinstance(plan2.get("steps", []), list) and len(plan2["steps"]) > 0:
866
+ return plan2
867
+ except Exception:
868
+ pass
869
+
870
+ # Fallbacks for common imperative phrasing (expanded to include 'folder')
871
+ if any(k in s for k in ["make a directory", "create a directory", "create a folder", "make a folder", "mkdir"]):
872
+ m = re.search(r"(?:named|called)?\s*([A-Za-z0-9._-]+)", prompt, re.I)
873
+ if m:
874
+ name = m.group(1)
875
+ path = f"./{name}"
876
+ ps_path = path.replace('"', '`"')
877
+ return {"steps": [{
878
+ "type": "shell",
879
+ "cmd": {
880
+ "linux": f'mkdir -p "{path}"',
881
+ "darwin": f'mkdir -p "{path}"',
882
+ "windows": f'powershell -NoProfile -Command "New-Item -ItemType Directory -Path \\"{ps_path}\\" -Force | Out-Null"'
883
+ }
884
+ }]}
885
+
886
+ m3 = re.search(r"(?:write|create|generate)\s+(.+?)\s+in\s+([A-Za-z0-9._-]+)\s+at\s+(/[\w\-/]+)", prompt, re.I)
887
+ if m3:
888
+ what, filename, base = m3.groups()
889
+ return {"steps": [{
890
+ "type": "generate_file",
891
+ "path": f"{base.rstrip('/')}/{filename}",
892
+ "instruction": what.strip(),
893
+ "format": "text",
894
+ "length": "medium"
895
+ }]}
896
+
897
+ # Final safe fallback: let the model answer
898
+ return {"steps": [{"type": "respond_llm", "instruction": f"Answer: {prompt}", "use_previous": False}]}
899
+
900
+ def ensure_concluding_response(plan: dict, user_prompt: str) -> dict:
901
+ """
902
+ If a plan has no concluding respond/respond_llm step, append a generic
903
+ 'respond_llm' that answers the user's prompt using previous step outputs.
904
+ This is command-agnostic and fixes cases like 'read file ... tell me ...'
905
+ where the model forgot to add a summarization step.
906
+ """
907
+ steps = plan.get("steps", [])
908
+ if not isinstance(steps, list):
909
+ steps = []
910
+ plan["steps"] = steps
911
+
912
+ # If there is already a respond/respond_llm step, keep as-is.
913
+ for s in steps:
914
+ t = (s.get("type") or "").lower()
915
+ if t in {"respond", "respond_llm"}:
916
+ # only default to True if the step didn't specify a preference
917
+ if t == "respond_llm" and ("use_previous" not in s):
918
+ s["use_previous"] = True
919
+ return plan
920
+
921
+
922
+ # Otherwise, append a concluding answer step that uses prior context.
923
+ steps.append({
924
+ "type": "respond_llm",
925
+ "instruction": f"Answer the user's request: {user_prompt}",
926
+ "use_previous": True
927
+ })
928
+ return plan
929
+
930
+ def _likely_needs_io(user_text: str) -> bool:
931
+ # Generic, non-brittle signal: absolute paths, path-like tokens, or classic IO verbs.
932
+ s = user_text.lower()
933
+ pathish = bool(re.search(r"(/|\\)[^\\s]+", user_text))
934
+ verbs = any(v in s for v in [
935
+ "ls","list","contents","show","cat","read","remove","delete","mkdir",
936
+ "create file","write","append","copy","move","save",
937
+ "open browser","open url","search","google","bing","duckduckgo","browse"
938
+ ])
939
+
940
+ return pathish or verbs
941
+
942
+ def _force_actionable_if_needed(model_pipe, user_prompt: str, first_plan: dict, context: str):
943
+ if _has_actionable(first_plan.get("steps")):
944
+ return first_plan
945
+ if not _likely_needs_io(user_prompt):
946
+ return first_plan
947
+ # Re-ask with a hard constraint
948
+ FORCE = PLANNER_SCHEMA + "\nYour previous plan lacked a tool step. The user request needs system I/O.\nReturn a plan that USES TOOLS (e.g., fs), then a single respond_llm."
949
+ raw = llm_chat(model_pipe, FORCE, f"USER_INSTRUCTION: {user_prompt}\nSERVER_OS: {SERVER_OS}\nCONTEXT:{context}\nRETURN JSON NOW:")
950
+ try:
951
+ plan2 = extract_first_json_object(raw)
952
+ if _has_actionable(plan2.get("steps")):
953
+ return plan2
954
+ except Exception:
955
+ pass
956
+ return first_plan
957
+
958
+ model = load_llm()
959
+
960
+ @app.route("/gen", methods=["POST"])
961
+ @require_api_key
962
+ def gen():
963
+ payload = request.json or {}
964
+ fmt = payload.get("format","text")
965
+ instruction = payload.get("instruction","")
966
+ length = payload.get("length","medium")
967
+
968
+ # If caller already handed us the exact file body, just return it.
969
+ if _looks_like_literal_content(None, fmt, instruction):
970
+ content = _sanitize_generated_content(None, fmt, instruction)
971
+ return jsonify({"content": content})
972
+
973
+ # Otherwise generate deterministically and sanitize.
974
+ lang_hint = ""
975
+ if isinstance(fmt, str) and fmt.startswith("code:"):
976
+ lang_hint = f"\nLanguage: {fmt.split(':',1)[1]}"
977
+ fmt = "text"
978
+ sys_prompt = "Return ONLY the exact file content asked for. No explanations, no code fences, no headers."
979
+ size_hint = {"short":400,"medium":1200,"long":2400,"xl":4800}.get(length,1200)
980
+ user_prompt = f"Format: {fmt}{lang_hint}\nInstruction: {instruction}\n"
981
+ raw = llm_generate_text_exact(model, sys_prompt, user_prompt, max_new_tokens=size_hint)
982
+ content = _sanitize_generated_content(None, fmt, raw)
983
+ return jsonify({"content": content})
984
+
985
+ def _get_cmd_string(cmd_value):
986
+ if isinstance(cmd_value, str):
987
+ return cmd_value
988
+ if isinstance(cmd_value, dict):
989
+ return (cmd_value.get(SERVER_OS)
990
+ or (cmd_value.get("unix") if SERVER_OS in ("linux","darwin") else None)
991
+ or cmd_value.get("default")
992
+ or next((v for v in cmd_value.values() if isinstance(v,str) and v.strip()), ""))
993
+ return ""
994
+
995
+ def _strip_browser_opens(plan: dict, prompt: str) -> dict:
996
+ # If it's a plain informational request, drop 'open/start http...' shell steps
997
+ if _likely_needs_io(prompt):
998
+ return plan
999
+ steps = plan.get("steps", [])
1000
+ for s in steps:
1001
+ if (s.get("type") == "shell"):
1002
+ cmd = _get_cmd_string(s.get("cmd"))
1003
+ if re.search(r"\b(open|start)\b.+https?://", cmd):
1004
+ return {"steps":[{"type":"respond_llm",
1005
+ "instruction":f"Answer clearly in 2–4 sentences. Do NOT repeat the question.\n\nQuestion: {prompt.strip()}",
1006
+ "use_previous":False}]}
1007
+
1008
+ return plan
1009
+
1010
+ def _ensure_qa_instruction(plan: dict, prompt: str) -> dict:
1011
+ """
1012
+ If there are no actionable steps (fs/shell/etc.) and the plan ends in a respond step,
1013
+ turn that into an explicit 'answer the question' instruction (no echo).
1014
+ """
1015
+ steps = plan.get("steps") or []
1016
+ actionable_before = any(
1017
+ (s.get("type","").lower() in ACTIONABLE)
1018
+ for s in steps
1019
+ if s.get("type","").lower() not in {"respond","respond_llm"}
1020
+ )
1021
+ if not actionable_before and steps:
1022
+ last = steps[-1]
1023
+ last["type"] = "respond_llm"
1024
+ last["instruction"] = (
1025
+ "Answer clearly in 2–4 sentences. Do NOT repeat the question. "
1026
+ "Do NOT claim to have opened a browser or searched the web.\n\n"
1027
+ f"Question: {prompt.strip()}"
1028
+ )
1029
+ last["use_previous"] = False
1030
+ return plan
1031
+
1032
+ @app.route("/infer", methods=["POST"])
1033
+ @require_api_key
1034
+ def infer():
1035
+ payload = request.json or {}
1036
+ prompt = payload.get("prompt", "")
1037
+ context = payload.get("context", "")
1038
+
1039
+ plan = plan_actions_from_prompt(model, prompt, context)
1040
+ plan = _force_actionable_if_needed(model, prompt, plan, context)
1041
+ plan = _strip_browser_opens(plan, prompt)
1042
+
1043
+ # ⬇️ ADD THIS LINE
1044
+ plan = _ensure_qa_instruction(plan, prompt)
1045
+
1046
+ plan = ensure_concluding_response(plan, prompt)
1047
+ return jsonify({"plan": plan})
1048
+
1049
+
1050
+ def resolve_cmd_by_os(cmd_value):
1051
+ """
1052
+ Accepts either a string or a dict of {os_name: cmd}.
1053
+ Picks the right command for SERVER_OS, with sensible fallbacks.
1054
+ """
1055
+ if isinstance(cmd_value, str):
1056
+ return cmd_value
1057
+ if isinstance(cmd_value, dict):
1058
+ # Exact match first
1059
+ c = cmd_value.get(SERVER_OS)
1060
+ if c:
1061
+ return c
1062
+ # 'unix' fallback for linux/darwin
1063
+ if SERVER_OS in ("linux", "darwin") and cmd_value.get("unix"):
1064
+ return cmd_value["unix"]
1065
+ # 'default' fallback
1066
+ if cmd_value.get("default"):
1067
+ return cmd_value["default"]
1068
+ # last resort: first non-empty string value
1069
+ for v in cmd_value.values():
1070
+ if isinstance(v, str) and v.strip():
1071
+ return v
1072
+ raise ValueError("Invalid 'cmd' in shell step: expected string or {os: cmd} map.")
1073
+
1074
+ def resolve_requires_by_os(req_value):
1075
+ if not req_value:
1076
+ return []
1077
+ if isinstance(req_value, str):
1078
+ return [req_value]
1079
+ if isinstance(req_value, list):
1080
+ return [x for x in req_value if isinstance(x, str)]
1081
+ if isinstance(req_value, dict):
1082
+ v = req_value.get(SERVER_OS)
1083
+ if v is None and SERVER_OS in ("linux", "darwin"):
1084
+ v = req_value.get("unix")
1085
+ if v is None:
1086
+ v = req_value.get("default")
1087
+ if isinstance(v, str):
1088
+ return [v]
1089
+ if isinstance(v, list):
1090
+ return [x for x in v if isinstance(x, str)]
1091
+ return []
1092
+
1093
+ def _which(cmd: str) -> bool:
1094
+ return bool(shutil.which(cmd))
1095
+
1096
+ def _guess_tools_from_cmd(cmd: str) -> list[str]:
1097
+ KNOWN = {"dig","nmap","whois","traceroute","nslookup","curl","wget","jq","git",
1098
+ "python3","python","pip","pip3","node","npm"}
1099
+ try:
1100
+ first = shlex.split(cmd)[0] if cmd else ""
1101
+ except Exception:
1102
+ first = (cmd or "").strip().split(" ", 1)[0]
1103
+ return [first] if first in KNOWN else []
1104
+
1105
+ def _detect_linux_pkg_mgr():
1106
+ try:
1107
+ with open("/etc/os-release","r") as f:
1108
+ data = f.read().lower()
1109
+ def has(*keys): return any(k in data for k in keys)
1110
+ if has("id_like=debian","id=debian","id=ubuntu","ubuntu"): return "apt"
1111
+ if has("id=fedora","id_like=fedora","id=rhel","centos","rocky","almalinux","amzn"):
1112
+ return "dnf" if shutil.which("dnf") else "yum"
1113
+ if has("id_like=alpine","id=alpine"): return "apk"
1114
+ if has("id=arch","id_like=arch"): return "pacman"
1115
+ if has("opensuse","sles","suse"): return "zypper"
1116
+ except Exception:
1117
+ pass
1118
+ for pm in ("apt","dnf","yum","apk","pacman","zypper"):
1119
+ if shutil.which(pm): return pm
1120
+ return None
1121
+
1122
+ _TOOL_PKG_MAP = {
1123
+ "dig": {"apt":"dnsutils","dnf":"bind-utils","yum":"bind-utils","apk":"bind-tools","pacman":"bind","zypper":"bind-utils"},
1124
+ "nslookup": {"apt":"dnsutils","dnf":"bind-utils","yum":"bind-utils","apk":"bind-tools","pacman":"bind","zypper":"bind-utils"},
1125
+ "whois": {"apt":"whois","dnf":"whois","yum":"whois","apk":"whois","pacman":"whois","zypper":"whois"},
1126
+ "nmap": {"apt":"nmap","dnf":"nmap","yum":"nmap","apk":"nmap","pacman":"nmap","zypper":"nmap"},
1127
+ "traceroute": {"apt":"traceroute","dnf":"traceroute","yum":"traceroute","apk":"traceroute","pacman":"traceroute","zypper":"traceroute"},
1128
+
1129
+ "python3": {"apt":"python3","dnf":"python3","yum":"python3","apk":"python3","pacman":"python","zypper":"python3"},
1130
+ "pip": {"apt":"python3-pip","dnf":"python3-pip","yum":"python3-pip","apk":"py3-pip","pacman":"python-pip","zypper":"python3-pip"},
1131
+ "node": {"apt":"nodejs","dnf":"nodejs","yum":"nodejs","apk":"nodejs","pacman":"nodejs","zypper":"nodejs"},
1132
+ "npm": {"apt":"npm","dnf":"npm","yum":"npm","apk":"npm","pacman":"npm","zypper":"npm"},
1133
+ }
1134
+
1135
+
1136
+ def _pkg_for_tool(tool: str, pm: str) -> str:
1137
+ return _TOOL_PKG_MAP.get(tool.lower(), {}).get(pm, tool)
1138
+
1139
+ def _install_missing_tools(tools: list[str]) -> tuple[bool,str,list[str]]:
1140
+ if SERVER_OS != "linux":
1141
+ return False, "Auto-install only supported on Linux.", []
1142
+ pm = _detect_linux_pkg_mgr()
1143
+ if not pm:
1144
+ return False, "Could not detect Linux package manager.", []
1145
+ pkgs = [_pkg_for_tool(t, pm) for t in tools]
1146
+ if pm == "apt":
1147
+ cmds = ["apt-get update", "apt-get install -y " + " ".join(pkgs)]
1148
+ elif pm in ("dnf","yum"):
1149
+ cmds = [f"{pm} -y install " + " ".join(pkgs)]
1150
+ elif pm == "apk":
1151
+ cmds = ["apk add --no-cache " + " ".join(pkgs)]
1152
+ elif pm == "pacman":
1153
+ cmds = ["pacman -Sy --noconfirm " + " ".join(pkgs)]
1154
+ elif pm == "zypper":
1155
+ cmds = ["zypper -n install " + " ".join(pkgs)]
1156
+ else:
1157
+ return False, f"Unsupported package manager: {pm}", []
1158
+
1159
+ log = []
1160
+ for c in cmds:
1161
+ proc = subprocess.run(c, shell=True, capture_output=True, text=True)
1162
+ log.append(f"$ {c}\n{proc.stdout}{proc.stderr}")
1163
+ if proc.returncode != 0:
1164
+ return False, "\n".join(log), []
1165
+ return True, "\n".join(log), pkgs
1166
+
1167
+ def _suggest_install_cmd(tools: list[str]) -> str:
1168
+ if SERVER_OS == "linux":
1169
+ pm = _detect_linux_pkg_mgr()
1170
+ if pm:
1171
+ pkgs = " ".join([_pkg_for_tool(t, pm) for t in tools])
1172
+ if pm == "apt": return f"sudo apt-get update && sudo apt-get install -y {pkgs}"
1173
+ if pm in ("dnf","yum"): return f"sudo {pm} -y install {pkgs}"
1174
+ if pm == "apk": return f"sudo apk add --no-cache {pkgs}"
1175
+ if pm == "pacman": return f"sudo pacman -Sy --noconfirm {pkgs}"
1176
+ if pm == "zypper": return f"sudo zypper -n install {pkgs}"
1177
+ return "Install the required tools with your distro's package manager."
1178
+ if SERVER_OS == "darwin":
1179
+ return "brew install " + " ".join(tools) + " # Requires Homebrew"
1180
+ if SERVER_OS == "windows":
1181
+ if shutil.which("winget"): return "winget install " + " ".join(tools)
1182
+ if shutil.which("choco"): return "choco install -y " + " ".join(tools)
1183
+ return "Install the tools manually or via winget/choco."
1184
+
1185
+ @app.route("/execute", methods=["POST"])
1186
+ @require_api_key
1187
+ def execute():
1188
+ def collect_text_context(results_so_far: list[dict]) -> str:
1189
+ chunks = []
1190
+ for r in results_so_far:
1191
+ t = r.get("type")
1192
+ if t == "read_file":
1193
+ chunks.append(r.get("content", ""))
1194
+ elif t == "shell":
1195
+ out = (r.get("stdout") or "") + ("\n" + r.get("stderr") if r.get("stderr") else "")
1196
+ if out.strip():
1197
+ chunks.append(out)
1198
+ elif t == "python":
1199
+ if r.get("stdout", "").strip():
1200
+ chunks.append(r["stdout"])
1201
+ elif t == "list_dir":
1202
+ ents = r.get("entries", [])
1203
+ if ents:
1204
+ chunks.append("\n".join(ents))
1205
+ elif t == "fs":
1206
+ op = r.get("op")
1207
+ if op == "read":
1208
+ chunks.append(r.get("content", ""))
1209
+ elif op == "exists":
1210
+ chunks.append(f"EXISTS {r.get('path')}: {r.get('exists')}")
1211
+ elif op == "glob":
1212
+ matches = r.get("matches", []) or []
1213
+ patt = r.get("pattern", "")
1214
+ header = f"GLOB {patt}\nCOUNT: {len(matches)}"
1215
+ body = ("\n".join(matches)) if matches else ""
1216
+ chunks.append(f"{header}\n{body}".strip())
1217
+ elif op == "list":
1218
+ entries = r.get("entries", []) or []
1219
+ p = r.get("path", "")
1220
+ header = f"LIST {p}\nCOUNT: {len(entries)}"
1221
+ body = ("\n".join(entries)) if entries else ""
1222
+ chunks.append(f"{header}\n{body}".strip())
1223
+ return "\n\n".join([c for c in chunks if c.strip()])
1224
+
1225
+ plan = (request.json or {}).get("plan", {})
1226
+ steps = plan.get("steps", [])
1227
+ results = []
1228
+
1229
+ for idx, step in enumerate(steps, 1):
1230
+ t = step.get("type")
1231
+ started = time.time()
1232
+ try:
1233
+ if t == "respond_llm":
1234
+ instruction = step.get("instruction", "").strip() or "Provide a clear, helpful answer."
1235
+ use_prev = bool(step.get("use_previous", True))
1236
+ extra_ctx = step.get("context", "")
1237
+ ctx = extra_ctx
1238
+ if use_prev:
1239
+ prev_text = collect_text_context(results)
1240
+ if prev_text:
1241
+ ctx = (ctx + "\n\n" + prev_text).strip() if ctx else prev_text
1242
+
1243
+ # --- Key change: extract the actual question and send ONLY that as the user message ---
1244
+ question = _extract_question_from_instruction(instruction)
1245
+
1246
+ if ctx:
1247
+ sys_prompt = (
1248
+ "You are a precise assistant. Use ONLY the provided context; do not guess. "
1249
+ "If the answer is not present, say 'Insufficient data.' "
1250
+ "Answer in 2–4 sentences and do NOT repeat or quote the question."
1251
+ )
1252
+ user_prompt = f"{question}\n\n--- Context ---\n{ctx}"
1253
+ else:
1254
+ sys_prompt = (
1255
+ "You are a precise assistant. Answer the question directly in 2–4 sentences. "
1256
+ "Do NOT repeat or quote the question. "
1257
+ "Do NOT claim to have opened a browser, clicked anything, or searched the web."
1258
+ )
1259
+
1260
+ user_prompt = question
1261
+
1262
+ # === Generate an initial answer (so later checks have a value) ===
1263
+ answer = llm_generate_text(model, sys_prompt, user_prompt, max_new_tokens=300).strip()
1264
+ # --- Deterministic extractors / fast-paths before LLM ---
1265
+ import re as _re
1266
+ low_inst = (instruction or "").lower()
1267
+ q_low = (question or "").lower()
1268
+
1269
+ if ctx:
1270
+ # 1) Node + npm versions (from: `node -v && npm -v`)
1271
+ if ("node" in low_inst and "npm" in low_inst and
1272
+ ("version" in low_inst or "versions" in low_inst)):
1273
+ vers = _re.findall(r"(?:^|\s)(v?\d+\.\d+\.\d+)(?=\s|$)", ctx)
1274
+ if len(vers) >= 2:
1275
+ node_v = vers[0] if vers[0].startswith("v") else "v" + vers[0]
1276
+ npm_v = vers[1].lstrip("v")
1277
+ res_obj = {"type": "respond", "text": f"Node.js {node_v}; npm {npm_v}.", "ok": True}
1278
+ results.append(res_obj); continue
1279
+
1280
+ # 2) Simple numeric answer (e.g., Python computed count)
1281
+ if ("number" in low_inst or "how many" in q_low) and _re.fullmatch(r"\s*\d+\s*", (ctx or "")):
1282
+ n = (ctx or "").strip()
1283
+ res_obj = {"type": "respond", "text": n, "ok": True}
1284
+ results.append(res_obj); continue
1285
+
1286
+ # 3) Reverse DNS (PTR) β€” extract the first hostname-looking token from context
1287
+ if ("reverse dns" in low_inst or "ptr" in low_inst or "rdns" in low_inst or "reverse dns" in q_low or "ptr" in q_low):
1288
+ m_host = _re.search(r"([a-z0-9](?:[a-z0-9\-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9\-]*[a-z0-9])?)+\.?)", ctx, _re.I)
1289
+ if m_host:
1290
+ host = m_host.group(1)
1291
+ res_obj = {"type": "respond", "text": f"PTR β†’ {host}", "ok": True}
1292
+ results.append(res_obj); continue
1293
+
1294
+ # 4) WHOIS β€” show first lines we captured; keep it short but useful
1295
+ if ("whois" in low_inst or "whois" in q_low) and ctx.strip():
1296
+ lines = [ln for ln in ctx.splitlines() if ln.strip()]
1297
+ # keep the most informative early lines
1298
+ head = "\n".join(lines[:25]) if lines else ""
1299
+ text = head or "WHOIS output was empty."
1300
+ res_obj = {"type": "respond", "text": text, "ok": True}
1301
+ results.append(res_obj); continue
1302
+
1303
+ # 5) β€œList/show/print” results when we already have them in context
1304
+ showish = any(w in q_low for w in ["show", "display", "print", "final contents", "list"])
1305
+ mentions_logs = ("log file" in q_low) or ("*.log" in q_low) or ("log files" in q_low) or ("glob" in ctx.lower())
1306
+ if showish or mentions_logs or "contents" in q_low:
1307
+ # If we have a GLOB summary, format it a bit
1308
+ if "GLOB " in ctx:
1309
+ # Keep header + up to 50 matches
1310
+ parts = ctx.splitlines()
1311
+ header = next((p for p in parts if p.startswith("GLOB ")), None)
1312
+ count = next((p for p in parts if p.startswith("COUNT:")), None)
1313
+ matches = [p for p in parts if p and not p.startswith(("GLOB ", "COUNT:"))]
1314
+ body = "\n".join(matches[:50]) if matches else "(no matches)"
1315
+ text = "\n".join([x for x in [header, count, body] if x])
1316
+ res_obj = {"type": "respond", "text": text, "ok": True}
1317
+ results.append(res_obj); continue
1318
+ # Otherwise just return the captured context (file contents, exist checks, shell output, etc.)
1319
+ if ctx.strip():
1320
+ res_obj = {"type": "respond", "text": ctx.strip(), "ok": True}
1321
+ results.append(res_obj); continue
1322
+
1323
+ def _looks_like_echo(ans: str, q: str) -> bool:
1324
+ a = _re.sub(r"\s+", " ", (ans or "").lower()).strip().rstrip("?.!")
1325
+ qq = _re.sub(r"\s+", " ", (q or "").lower()).strip().rstrip("?.!")
1326
+ # treat short or identical answers as echoes
1327
+ return (not a) or (a == qq) or a.startswith(("answer clearly", "question:", "instruction:"))
1328
+
1329
+ # Retry once if echo-ish
1330
+ if _looks_like_echo(answer, question):
1331
+ retry_user = f"{question}\n\nRespond in 2–4 sentences. Do NOT repeat or quote the question."
1332
+ answer = llm_generate_text(
1333
+ model,
1334
+ sys_prompt,
1335
+ retry_user,
1336
+ max_new_tokens=300
1337
+ ).strip()
1338
+
1339
+ # Guardrail 1: if the model claims "Insufficient data" but we DO have context, show the context/summarize
1340
+ if ctx and "insufficient data" in (answer or "").lower():
1341
+ # Prefer showing structured ctx (e.g., GLOB header) or first 25 lines
1342
+ lines = [ln for ln in ctx.splitlines() if ln.strip()]
1343
+ answer = "\n".join(lines[:25]) if lines else ctx.strip()
1344
+
1345
+ # Final clean & fallback
1346
+ answer = _strip_meta_lines(answer)
1347
+
1348
+ # Guardrail 2: if answer has almost no overlap with ctx, produce a deterministic summary
1349
+ if ctx:
1350
+ import re as _re
1351
+ ctx_tokens = set(_re.findall(r"[A-Za-z0-9_.:/-]+", ctx.lower()))
1352
+ ans_tokens = set(_re.findall(r"[A-Za-z0-9_.:/-]+", (answer or "").lower()))
1353
+ if len(ctx_tokens & ans_tokens) < 3:
1354
+ # small, factual summary of context only
1355
+ answer = llm_generate_text_exact(
1356
+ model,
1357
+ "Summarize ONLY the provided text into 2–4 short sentences. No new facts.",
1358
+ ctx,
1359
+ max_new_tokens=160
1360
+ ).strip()
1361
+
1362
+ # Only fall back if we somehow still have no answer
1363
+ if not answer.strip():
1364
+ prev_text = collect_text_context(results)
1365
+ answer = (prev_text[:800].strip() if prev_text else "Sorry β€” I couldn’t produce an answer.")
1366
+
1367
+ res_obj = {"type": "respond", "text": answer, "ok": True}
1368
+
1369
+ elif t == "respond":
1370
+ # Normal respond β€” but upgrade placeholders to real answers using previous context.
1371
+ text = step.get("text", "")
1372
+ placeholdery = (not text.strip()) or (text.strip().lower() in {"acknowledged.", "ok.", "okay.", "acknowledged"}) or ("<insert" in text.lower())
1373
+ if placeholdery:
1374
+ prev_text = collect_text_context(results)
1375
+ if prev_text:
1376
+ sys_prompt = (
1377
+ "You convert raw outputs into a concise, friendly explanation. "
1378
+ "Summarize what's most important for the user in a few sentences or bullets."
1379
+ )
1380
+ # Try to infer intent from the placeholder; default to 'summarize'
1381
+ instruction = step.get("instruction", "Summarize the provided content.")
1382
+ user_prompt = f"{instruction}\n\n---\n{prev_text}\n---"
1383
+ text = llm_generate_text(model, sys_prompt, user_prompt, max_new_tokens=600).strip()
1384
+ res_obj = {"type": "respond", "text": text, "ok": True}
1385
+
1386
+ elif t == "mkdirs":
1387
+ made = []
1388
+ for d in step.get("paths", []):
1389
+ if not d: continue
1390
+ os.makedirs(d, exist_ok=True)
1391
+ made.append(d)
1392
+ res_obj = {"type":"mkdirs","created":made,"ok":True}
1393
+
1394
+ elif t == "rewrite_file":
1395
+ path = step["path"]
1396
+ instruction = step.get("instruction", "")
1397
+ length = step.get("length", "long")
1398
+ size_hint = {"short":400,"medium":1200,"long":2400,"xl":4800}.get(length, 2400)
1399
+
1400
+ try:
1401
+ with open(path, "r", errors="ignore") as f:
1402
+ current = f.read()
1403
+ except FileNotFoundError:
1404
+ current = ""
1405
+
1406
+ sys_prompt = (
1407
+ "You are editing a single file. Return ONLY the full, final file content. "
1408
+ "No explanations, no backticks."
1409
+ )
1410
+ user_prompt = (
1411
+ f"Instruction:\n{instruction}\n\n"
1412
+ f"--- CURRENT FILE CONTENT START ---\n{current}\n--- CURRENT FILE CONTENT END ---"
1413
+ )
1414
+ new_content = llm_generate_text(model, sys_prompt, user_prompt, max_new_tokens=size_hint)
1415
+ new_content = new_content.strip().removeprefix("```").removesuffix("```").strip()
1416
+
1417
+ os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
1418
+ with open(path, "w", encoding="utf-8") as f:
1419
+ f.write(new_content)
1420
+
1421
+ res_obj = {"type":"rewrite_file","path":path,"bytes":len(new_content.encode('utf-8')),"ok":True}
1422
+
1423
+ elif t == "fs":
1424
+ op = (step.get("op") or "").lower()
1425
+ path = step.get("path")
1426
+
1427
+ # safety: normalize/expand
1428
+ if path:
1429
+ path = os.path.expanduser(path)
1430
+
1431
+ if op == "list":
1432
+ entries = sorted(os.listdir(path))
1433
+ res_obj = {"type": "fs", "op": op, "path": path, "entries": entries, "count": len(entries), "ok": True}
1434
+
1435
+ elif op == "read":
1436
+ with open(path, "r", errors="ignore") as f:
1437
+ content = f.read()
1438
+ res_obj = {"type": "fs", "op": op, "path": path, "content": content, "bytes": len(content.encode()), "ok": True}
1439
+
1440
+ elif op == "write":
1441
+ content = step.get("content", "")
1442
+ os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
1443
+ with open(path, "w", encoding="utf-8") as f:
1444
+ f.write(content)
1445
+ res_obj = {"type": "fs", "op": op, "path": path, "bytes": len(content.encode()), "ok": True}
1446
+
1447
+ elif op == "append":
1448
+ content = step.get("content", "")
1449
+ os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
1450
+ with open(path, "a", encoding="utf-8") as f:
1451
+ f.write(content)
1452
+ res_obj = {"type": "fs", "op": op, "path": path, "bytes": len(content.encode()), "ok": True}
1453
+
1454
+ elif op == "mkdir":
1455
+ os.makedirs(path, exist_ok=True)
1456
+ res_obj = {"type": "fs", "op": op, "path": path, "ok": True}
1457
+
1458
+ elif op == "remove":
1459
+ # safe-ish delete: file or empty dir (no recursive by default)
1460
+ if os.path.isdir(path):
1461
+ os.rmdir(path) # raises if not empty (good)
1462
+ else:
1463
+ os.remove(path)
1464
+ res_obj = {"type": "fs", "op": op, "path": path, "ok": True}
1465
+
1466
+ elif op == "move":
1467
+ to = os.path.expanduser(step["to"])
1468
+ os.makedirs(os.path.dirname(to) or ".", exist_ok=True)
1469
+ os.replace(path, to)
1470
+ res_obj = {"type": "fs", "op": op, "path": path, "to": to, "ok": True}
1471
+
1472
+ elif op == "copy":
1473
+ to = os.path.expanduser(step["to"])
1474
+ os.makedirs(os.path.dirname(to) or ".", exist_ok=True)
1475
+ shutil.copy2(path, to)
1476
+ res_obj = {"type": "fs", "op": op, "path": path, "to": to, "ok": True}
1477
+
1478
+ elif op == "exists":
1479
+ res_obj = {"type": "fs", "op": op, "path": path, "exists": os.path.exists(path), "ok": True}
1480
+
1481
+ elif op == "glob":
1482
+ import glob
1483
+ patt = step.get("pattern") or path
1484
+ if path and step.get("pattern"):
1485
+ base = os.path.expanduser(path)
1486
+ patt = os.path.join(base, step["pattern"])
1487
+ matches = sorted(glob.glob(os.path.expanduser(patt)))
1488
+ res_obj = {"type": "fs", "op": op, "pattern": patt, "matches": matches, "count": len(matches), "ok": True}
1489
+
1490
+ else:
1491
+ res_obj = {"type": "error", "error": f"Unknown fs op '{op}'", "ok": False}
1492
+
1493
+ elif t == "generate_tree":
1494
+ base = step.get("base") or "."
1495
+ files = step.get("files") or []
1496
+ os.makedirs(base, exist_ok=True)
1497
+ written = []
1498
+ for f in files:
1499
+ rel = f.get("path")
1500
+ if not rel: continue
1501
+ path = os.path.join(base, rel)
1502
+ os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
1503
+ fmt = f.get("format","text")
1504
+ instr = f.get("instruction","")
1505
+ length= f.get("length","medium")
1506
+ # hint model with language if provided as code:<lang>
1507
+ lang_hint = ""
1508
+ if fmt.startswith("code:"):
1509
+ lang_hint = f"\nLanguage: {fmt.split(':',1)[1]}"
1510
+ fmt = "text"
1511
+ # Literal content? (most of our scaffolds pass the final body)
1512
+ if _looks_like_literal_content(path, fmt, instr) or os.path.basename(path).lower() == "requirements.txt":
1513
+ content = instr
1514
+ else:
1515
+ sys_prompt = "Return ONLY the exact file content asked for. No explanations, no code fences, no headers."
1516
+ size_hint = {"short":400, "medium":1200, "long":2400, "xl":4800}.get(length, 1200)
1517
+ user_prompt = f"Format: {fmt}{lang_hint}\nInstruction: {instr}\n"
1518
+ content = llm_generate_text_exact(model, sys_prompt, user_prompt, max_new_tokens=size_hint)
1519
+ content = _sanitize_generated_content(path, fmt, content)
1520
+ os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
1521
+ with open(path,"w",encoding="utf-8") as fp: fp.write(content)
1522
+ written.append({"path":path,"bytes":len(content.encode('utf-8'))})
1523
+ res_obj = {"type":"generate_tree","base":base,"written":written,"ok":True}
1524
+
1525
+ elif t == "generate_large_file":
1526
+ path = step["path"]
1527
+ chunks = step.get("chunks") or []
1528
+ os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
1529
+ total = 0
1530
+ with open(path,"w",encoding="utf-8") as fp:
1531
+ for i, ck in enumerate(chunks, 1):
1532
+ instr = ck.get("instruction","")
1533
+ length = ck.get("length","medium")
1534
+ size_hint = {"short":400, "medium":1200, "long":2400, "xl":4800}.get(length, 1200)
1535
+ sys_prompt = (
1536
+ "You are writing a specific section of a larger file. "
1537
+ "Write only the requested section. No preambles, no backticks, no repetition."
1538
+ )
1539
+ user_prompt = f"Section {i}/{len(chunks)}:\n{instr}"
1540
+ piece = llm_generate_text(model, sys_prompt, user_prompt, max_new_tokens=size_hint)
1541
+ piece = piece.strip().removeprefix("```").removesuffix("```").strip()
1542
+ fp.write(piece + ("\n" if not piece.endswith("\n") else ""))
1543
+ total += len(piece.encode("utf-8"))
1544
+ res_obj = {"type":"generate_large_file","path":path,"bytes":total,"chunks":len(chunks),"ok":True}
1545
+
1546
+ elif t == "shell":
1547
+ # Resolve command by OS first
1548
+ cmd = resolve_cmd_by_os(step["cmd"])
1549
+ cwd = step.get("cwd") or None
1550
+ timeout = float(step.get("timeout", 120))
1551
+ env = os.environ.copy()
1552
+ env.update(step.get("env", {}))
1553
+
1554
+ # Gather required tools: explicit + heuristic
1555
+ requires = resolve_requires_by_os(step.get("requires") or step.get("needs"))
1556
+ if not requires:
1557
+ requires = _guess_tools_from_cmd(cmd)
1558
+
1559
+ missing = [tool for tool in requires if not _which(tool)]
1560
+ preinstall_log = ""
1561
+
1562
+ if missing:
1563
+ if ALLOW_AUTO_INSTALL:
1564
+ ok_install, log, _installed = _install_missing_tools(missing)
1565
+ preinstall_log = log
1566
+ if not ok_install:
1567
+ res_obj = {
1568
+ "type": "shell",
1569
+ "cmd": cmd,
1570
+ "cwd": cwd,
1571
+ "stdout": "",
1572
+ "stderr": (
1573
+ "Missing tools: " + ", ".join(missing) +
1574
+ "\nAuto-install failed or not supported.\n" + log +
1575
+ "\nTry manually: " + _suggest_install_cmd(missing)
1576
+ ),
1577
+ "returncode": 127,
1578
+ "ok": False,
1579
+ "preinstall": preinstall_log,
1580
+ }
1581
+ results.append(res_obj)
1582
+ continue
1583
+ else:
1584
+ res_obj = {
1585
+ "type": "shell",
1586
+ "cmd": cmd,
1587
+ "cwd": cwd,
1588
+ "stdout": "",
1589
+ "stderr": (
1590
+ "Missing tools: " + ", ".join(missing) +
1591
+ "\nAuto-install disabled (set ALLOW_AUTO_INSTALL=1 on the server to enable for Linux)." +
1592
+ "\nTry: " + _suggest_install_cmd(missing)
1593
+ ),
1594
+ "returncode": 127,
1595
+ "ok": False,
1596
+ }
1597
+ results.append(res_obj)
1598
+ continue
1599
+
1600
+ # Run the intended command
1601
+ proc = subprocess.run(
1602
+ cmd, shell=True, capture_output=True, text=True,
1603
+ cwd=cwd, timeout=timeout, env=env,
1604
+ )
1605
+ res_obj = {
1606
+ "type": "shell",
1607
+ "cmd": cmd,
1608
+ "cwd": cwd,
1609
+ "stdout": proc.stdout,
1610
+ "stderr": proc.stderr,
1611
+ "returncode": proc.returncode,
1612
+ "ok": (proc.returncode == 0),
1613
+ }
1614
+ if preinstall_log:
1615
+ res_obj["preinstall"] = preinstall_log
1616
+
1617
+
1618
+ elif t == "generate_file":
1619
+ path = step["path"]
1620
+ instruction = step.get("instruction", "")
1621
+ fmt = step.get("format", "text")
1622
+ length = step.get("length", "medium")
1623
+ lang_hint = ""
1624
+ if isinstance(fmt, str) and fmt.startswith("code:"):
1625
+ lang_hint = f"\nLanguage: {fmt.split(':',1)[1]}"
1626
+ fmt = "text"
1627
+ if _looks_like_literal_content(path, fmt, instruction) or os.path.basename(path).lower() == "requirements.txt":
1628
+ content = instruction
1629
+ else:
1630
+ sys_prompt = "Return ONLY the exact file content asked for. No explanations, no code fences, no headers."
1631
+ size_hint = {"short":400,"medium":1200,"long":2400}.get(length,1200)
1632
+ user_prompt = f"Format: {fmt}{lang_hint}\nInstruction: {instruction}\n"
1633
+ content = llm_generate_text_exact(model, sys_prompt, user_prompt, max_new_tokens=size_hint)
1634
+ content = _sanitize_generated_content(path, fmt, content)
1635
+ os.makedirs(os.path.dirname(path), exist_ok=True)
1636
+ with open(path, "w") as f:
1637
+ f.write(content)
1638
+ res_obj = {"type": "generate_file", "path": path, "status": "ok", "bytes": len(content.encode('utf-8')), "ok": True}
1639
+
1640
+ elif t == "read_file":
1641
+ path = step["path"]
1642
+ with open(path, "r", errors="ignore") as f:
1643
+ content = f.read()
1644
+ res_obj = {"type": "read_file", "path": path, "content": content, "bytes": len(content.encode("utf-8")), "line_count": (content.count("\n")+1 if content else 0), "ok": True}
1645
+
1646
+ elif t in ("write_file", "edit_file", "append_file"):
1647
+ path = step["path"]
1648
+ content = step.get("content", "")
1649
+ mode = "w" if t != "append_file" else "a"
1650
+ os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
1651
+ with open(path, mode) as f:
1652
+ f.write(content)
1653
+ res_obj = {"type": t, "path": path, "mode": mode, "status": "ok", "bytes": len(content.encode("utf-8")), "line_count": (content.count("\n")+1 if content else 0), "ok": True}
1654
+
1655
+ elif t == "list_dir":
1656
+ path = step.get("path", ".")
1657
+ entries = sorted(os.listdir(path))
1658
+ res_obj = {"type": "list_dir", "path": path, "entries": entries, "count": len(entries), "ok": True}
1659
+
1660
+ elif t == "python":
1661
+ code = step["code"]
1662
+ output = safe_exec_python(code)
1663
+ ok_flag = ("Traceback (most recent call last):" not in output)
1664
+ res_obj = {"type": "python", "stdout": output, "ok": ok_flag}
1665
+
1666
+ else:
1667
+ res_obj = {"type": "error", "error": f"Unknown step type {t}", "ok": False}
1668
+
1669
+ except Exception as e:
1670
+ res_obj = {"type": "error", "error": str(e), "trace": traceback.format_exc(), "step": step, "index": idx, "ok": False}
1671
+
1672
+ res_obj["duration_ms"] = int((time.time() - started) * 1000)
1673
+ results.append(res_obj)
1674
+
1675
+ return jsonify({"results": results})
1676
+
1677
+ # server (new endpoint)
1678
+ @app.route("/assist/rewrite", methods=["POST"])
1679
+ @require_api_key
1680
+ def assist_rewrite():
1681
+ j = request.json or {}
1682
+ instruction = j.get("instruction","")
1683
+ current = j.get("current","")
1684
+ length = j.get("length","long")
1685
+ sys = "You are editing a single file. Return ONLY the full, final file content. No backticks."
1686
+ user = f"Instruction:\n{instruction}\n\n--- CURRENT ---\n{current}\n--- END ---"
1687
+ out = llm_generate_text_exact(model, sys, user, max_new_tokens={"short":400,"medium":1200,"long":2400,"xl":4800}[length])
1688
+ return jsonify({"new_content": _sanitize_generated_content(None, "text", out)})
1689
+
1690
+ # ──────────────────────────────────────────────
1691
+ # 4) Main
1692
+ # ──────────────────────────────────────────────
1693
+ if __name__ == "__main__":
1694
+ with app.app_context():
1695
+ init_db()
1696
+ port = int(os.environ.get("PORT", 5005))
1697
+ print(f"[+] Llama3-Agent server running on http://0.0.0.0:{port}")
1698
+ app.run(host="0.0.0.0", port=port, debug=False)