opalacoder 0.1.0__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.
- opalacoder/__init__.py +2 -0
- opalacoder/agents.py +233 -0
- opalacoder/agents.yaml +78 -0
- opalacoder/api_keys.py +75 -0
- opalacoder/cli.py +339 -0
- opalacoder/cli_commands.py +277 -0
- opalacoder/config.py +215 -0
- opalacoder/embeddings.py +85 -0
- opalacoder/i18n.py +249 -0
- opalacoder/orchestrator.py +381 -0
- opalacoder/planner.py +206 -0
- opalacoder/project.py +196 -0
- opalacoder/session.py +4 -0
- opalacoder/skills/generaldeveloper.md +52 -0
- opalacoder/skills/html_css_js.md +51 -0
- opalacoder/skills/opalacoder.md +37 -0
- opalacoder/skills/python_subprocess.md +11 -0
- opalacoder/skills/react_vite.md +6 -0
- opalacoder/skills.py +184 -0
- opalacoder/structured.py +113 -0
- opalacoder/terminal.py +186 -0
- opalacoder/tools.py +351 -0
- opalacoder/vcs.py +254 -0
- opalacoder-0.1.0.dist-info/METADATA +230 -0
- opalacoder-0.1.0.dist-info/RECORD +27 -0
- opalacoder-0.1.0.dist-info/WHEEL +4 -0
- opalacoder-0.1.0.dist-info/entry_points.txt +2 -0
opalacoder/project.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Project management: create, load, save, and list OpalaCoder projects using SQLite."""
|
|
2
|
+
|
|
3
|
+
import sqlite3
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
from .config import DEFAULT_DB_PATH
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _ensure_dir(path: str) -> None:
|
|
14
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _conn(db_path: str) -> sqlite3.Connection:
|
|
18
|
+
_ensure_dir(db_path)
|
|
19
|
+
conn = sqlite3.connect(db_path)
|
|
20
|
+
conn.row_factory = sqlite3.Row
|
|
21
|
+
return conn
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _init_schema(db_path: str) -> None:
|
|
25
|
+
with _conn(db_path) as conn:
|
|
26
|
+
conn.executescript("""
|
|
27
|
+
CREATE TABLE IF NOT EXISTS projects (
|
|
28
|
+
name TEXT PRIMARY KEY,
|
|
29
|
+
created_at TEXT NOT NULL,
|
|
30
|
+
updated_at TEXT NOT NULL,
|
|
31
|
+
mode TEXT NOT NULL DEFAULT 'plan',
|
|
32
|
+
model TEXT NOT NULL DEFAULT '',
|
|
33
|
+
project_name TEXT NOT NULL DEFAULT '',
|
|
34
|
+
project_path TEXT NOT NULL DEFAULT '',
|
|
35
|
+
skills TEXT NOT NULL DEFAULT '["opalacoder"]',
|
|
36
|
+
description TEXT NOT NULL DEFAULT '',
|
|
37
|
+
request TEXT NOT NULL DEFAULT '',
|
|
38
|
+
plan_text TEXT NOT NULL DEFAULT '',
|
|
39
|
+
subplans TEXT NOT NULL DEFAULT '[]',
|
|
40
|
+
results TEXT NOT NULL DEFAULT '{}'
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
CREATE TABLE IF NOT EXISTS project_history (
|
|
44
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
45
|
+
project TEXT NOT NULL,
|
|
46
|
+
timestamp TEXT NOT NULL,
|
|
47
|
+
role TEXT NOT NULL,
|
|
48
|
+
content TEXT NOT NULL,
|
|
49
|
+
FOREIGN KEY (project) REFERENCES projects(name) ON DELETE CASCADE
|
|
50
|
+
);
|
|
51
|
+
""")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class ProjectData:
|
|
56
|
+
name: str
|
|
57
|
+
mode: str = "plan"
|
|
58
|
+
model: str = ""
|
|
59
|
+
project_name: str = ""
|
|
60
|
+
project_path: str = ""
|
|
61
|
+
skills: list = field(default_factory=lambda: ["opalacoder"])
|
|
62
|
+
description: str = ""
|
|
63
|
+
request: str = ""
|
|
64
|
+
plan_text: str = ""
|
|
65
|
+
subplans: list = field(default_factory=list)
|
|
66
|
+
results: dict = field(default_factory=dict)
|
|
67
|
+
history: list = field(default_factory=list) # [{role, content}]
|
|
68
|
+
|
|
69
|
+
def clear_state(self) -> None:
|
|
70
|
+
self.request = ""
|
|
71
|
+
self.plan_text = ""
|
|
72
|
+
self.subplans = []
|
|
73
|
+
self.results = {}
|
|
74
|
+
|
|
75
|
+
def context_header(self) -> str:
|
|
76
|
+
"""Returns a project context string to prepend to every prompt."""
|
|
77
|
+
name = self.project_name or self.name
|
|
78
|
+
path = self.project_path or "(unspecified)"
|
|
79
|
+
return f"[PROJECT: {name} | PATH: {path}]\n"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# Backward-compat alias so existing imports of SessionData still work during migration
|
|
83
|
+
SessionData = ProjectData
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class ProjectStore:
|
|
87
|
+
def __init__(self, db_path: str = DEFAULT_DB_PATH):
|
|
88
|
+
self.db_path = db_path
|
|
89
|
+
_init_schema(db_path)
|
|
90
|
+
|
|
91
|
+
def exists(self, name: str) -> bool:
|
|
92
|
+
with _conn(self.db_path) as conn:
|
|
93
|
+
row = conn.execute(
|
|
94
|
+
"SELECT 1 FROM projects WHERE name = ?", (name,)
|
|
95
|
+
).fetchone()
|
|
96
|
+
return row is not None
|
|
97
|
+
|
|
98
|
+
def list_projects(self) -> list[dict]:
|
|
99
|
+
with _conn(self.db_path) as conn:
|
|
100
|
+
rows = conn.execute(
|
|
101
|
+
"SELECT name, project_name, project_path, created_at, updated_at, mode FROM projects ORDER BY updated_at DESC"
|
|
102
|
+
).fetchall()
|
|
103
|
+
return [dict(r) for r in rows]
|
|
104
|
+
|
|
105
|
+
def create(self, name: str, mode: str, model: str, project_name: str = "", project_path: str = "", skills: list = None, description: str = "") -> ProjectData:
|
|
106
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
107
|
+
_skills = skills if skills is not None else ["opalacoder"]
|
|
108
|
+
if "opalacoder" not in _skills:
|
|
109
|
+
_skills = ["opalacoder"] + _skills
|
|
110
|
+
with _conn(self.db_path) as conn:
|
|
111
|
+
conn.execute(
|
|
112
|
+
"INSERT INTO projects (name, created_at, updated_at, mode, model, project_name, project_path, skills, description) VALUES (?,?,?,?,?,?,?,?,?)",
|
|
113
|
+
(name, now, now, mode, model, project_name, project_path, json.dumps(_skills), description),
|
|
114
|
+
)
|
|
115
|
+
return ProjectData(name=name, mode=mode, model=model, project_name=project_name, project_path=project_path, skills=_skills, description=description)
|
|
116
|
+
|
|
117
|
+
def overwrite(self, name: str, mode: str, model: str, project_name: str = "", project_path: str = "", skills: list = None, description: str = "") -> ProjectData:
|
|
118
|
+
self.delete(name)
|
|
119
|
+
return self.create(name, mode, model, project_name, project_path, skills, description)
|
|
120
|
+
|
|
121
|
+
def delete(self, name: str) -> None:
|
|
122
|
+
with _conn(self.db_path) as conn:
|
|
123
|
+
conn.execute("DELETE FROM projects WHERE name = ?", (name,))
|
|
124
|
+
conn.execute("DELETE FROM project_history WHERE project = ?", (name,))
|
|
125
|
+
|
|
126
|
+
def rename(self, old_name: str, new_name: str) -> bool:
|
|
127
|
+
if self.exists(new_name):
|
|
128
|
+
return False
|
|
129
|
+
with _conn(self.db_path) as conn:
|
|
130
|
+
conn.execute("UPDATE projects SET name=? WHERE name=?", (new_name, old_name))
|
|
131
|
+
conn.execute("UPDATE project_history SET project=? WHERE project=?", (new_name, old_name))
|
|
132
|
+
return True
|
|
133
|
+
|
|
134
|
+
def load(self, name: str) -> Optional[ProjectData]:
|
|
135
|
+
with _conn(self.db_path) as conn:
|
|
136
|
+
row = conn.execute(
|
|
137
|
+
"SELECT * FROM projects WHERE name = ?", (name,)
|
|
138
|
+
).fetchone()
|
|
139
|
+
if row is None:
|
|
140
|
+
return None
|
|
141
|
+
hist_rows = conn.execute(
|
|
142
|
+
"SELECT role, content FROM project_history WHERE project = ? ORDER BY id",
|
|
143
|
+
(name,),
|
|
144
|
+
).fetchall()
|
|
145
|
+
return ProjectData(
|
|
146
|
+
name=name,
|
|
147
|
+
mode=row["mode"],
|
|
148
|
+
model=row["model"],
|
|
149
|
+
project_name=row["project_name"],
|
|
150
|
+
project_path=row["project_path"],
|
|
151
|
+
skills=json.loads(row["skills"]),
|
|
152
|
+
description=row["description"],
|
|
153
|
+
request=row["request"],
|
|
154
|
+
plan_text=row["plan_text"],
|
|
155
|
+
subplans=json.loads(row["subplans"]),
|
|
156
|
+
results=json.loads(row["results"]),
|
|
157
|
+
history=[dict(r) for r in hist_rows],
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
def save(self, project: ProjectData) -> None:
|
|
161
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
162
|
+
_skills = list(project.skills)
|
|
163
|
+
if "opalacoder" not in _skills:
|
|
164
|
+
_skills = ["opalacoder"] + _skills
|
|
165
|
+
with _conn(self.db_path) as conn:
|
|
166
|
+
conn.execute(
|
|
167
|
+
"""UPDATE projects SET updated_at=?, mode=?, model=?, project_name=?, project_path=?,
|
|
168
|
+
skills=?, description=?, request=?, plan_text=?, subplans=?, results=? WHERE name=?""",
|
|
169
|
+
(
|
|
170
|
+
now,
|
|
171
|
+
project.mode,
|
|
172
|
+
project.model,
|
|
173
|
+
project.project_name,
|
|
174
|
+
project.project_path,
|
|
175
|
+
json.dumps(_skills, ensure_ascii=False),
|
|
176
|
+
project.description,
|
|
177
|
+
project.request,
|
|
178
|
+
project.plan_text,
|
|
179
|
+
json.dumps(project.subplans, ensure_ascii=False),
|
|
180
|
+
json.dumps(project.results, ensure_ascii=False),
|
|
181
|
+
project.name,
|
|
182
|
+
),
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
def append_message(self, project: ProjectData, role: str, content: str) -> None:
|
|
186
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
187
|
+
with _conn(self.db_path) as conn:
|
|
188
|
+
conn.execute(
|
|
189
|
+
"INSERT INTO project_history (project, timestamp, role, content) VALUES (?,?,?,?)",
|
|
190
|
+
(project.name, now, role, content),
|
|
191
|
+
)
|
|
192
|
+
project.history.append({"role": role, "content": content})
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# Backward-compat alias
|
|
196
|
+
SessionStore = ProjectStore
|
opalacoder/session.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
tags: criar, create, build, develop, implement, make, construir, desenvolver, implementar, programa, aplicativo, aplicação, sistema, projeto, app, software
|
|
2
|
+
description: Use ONLY when the user requests to BUILD, CREATE, or IMPLEMENT a new software project. When active, the agent must gather requirements via ask_human BEFORE any plan is generated.
|
|
3
|
+
scope: orchestrator
|
|
4
|
+
---
|
|
5
|
+
## General Developer — Pre-Planning Requirements Investigation
|
|
6
|
+
|
|
7
|
+
**THIS RULE IS MANDATORY AND MUST RUN BEFORE ANY PLAN IS CREATED.**
|
|
8
|
+
|
|
9
|
+
Whenever the user asks you to build, create, or implement any software, application, or feature,
|
|
10
|
+
you MUST NOT start generating a plan immediately.
|
|
11
|
+
|
|
12
|
+
Instead, you MUST first conduct a brief requirements elicitation interview using `ask_human`.
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
### Phase 0: Requirements Elicitation (Run BEFORE Panorama/Planning)
|
|
17
|
+
|
|
18
|
+
Call `ask_human` to ask the following questions. You may group them into one or two messages, but DO NOT skip any question.
|
|
19
|
+
|
|
20
|
+
**Question group 1 — Technology Stack:**
|
|
21
|
+
"Before I start planning, I need to understand your preferences:
|
|
22
|
+
1. Do you have a preferred technology stack? (e.g. plain HTML/CSS/JS, React, Vue, Next.js, Python Flask, FastAPI, Node.js, etc.) Or can I choose the most appropriate one?
|
|
23
|
+
2. Should I use any specific libraries or frameworks? Or do you prefer zero external dependencies?"
|
|
24
|
+
|
|
25
|
+
**Question group 2 — Functional Requirements:**
|
|
26
|
+
"Now, tell me about the features:
|
|
27
|
+
3. What are the CORE features this software must have? (List the must-haves)
|
|
28
|
+
4. Are there any features that would be nice to have but are not essential? (Nice-to-haves)"
|
|
29
|
+
|
|
30
|
+
**Question group 3 — Non-Functional Requirements:**
|
|
31
|
+
"Finally, some quality preferences:
|
|
32
|
+
5. Performance: Is speed a critical concern, or is correctness/simplicity more important?
|
|
33
|
+
6. Accessibility: Should it follow accessibility standards (WCAG / screen reader support)?
|
|
34
|
+
7. Responsiveness: Should it adapt to mobile screens, or is desktop-only acceptable?
|
|
35
|
+
8. Target environment: Where will this run? (Browser only, Node.js, desktop app, server, etc.)"
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
### After the Interview
|
|
40
|
+
|
|
41
|
+
Only after receiving the user's answers:
|
|
42
|
+
1. Summarize the confirmed requirements in a short bullet list.
|
|
43
|
+
2. Confirm with the user: "Based on your answers, here is what I will build: [summary]. Is this correct?"
|
|
44
|
+
3. Only after the user confirms, proceed to generate the plan (Panorama / Decomposition).
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
### Rules
|
|
49
|
+
- Never skip Phase 0. Even if the original request seems detailed, always ask at minimum questions 1 and 3.
|
|
50
|
+
- If the user says "you decide" or "whatever is best" for a question, that is a valid answer — record it as "agent's choice" and proceed.
|
|
51
|
+
- Keep the questions conversational and non-intimidating. Avoid technical jargon unless the user demonstrates technical knowledge.
|
|
52
|
+
- If the user has already provided some of this information in their request, acknowledge it and only ask about what is missing.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
tags: html, css, javascript, js, web, webpage, page, frontend, calculator, form, dom, vanilla
|
|
2
|
+
description: Use when the user requests a plain HTML/CSS/JavaScript project (no framework, no bundler).
|
|
3
|
+
scope: orchestrator
|
|
4
|
+
---
|
|
5
|
+
## HTML / CSS / JavaScript Developer Rules
|
|
6
|
+
|
|
7
|
+
WHEN the user explicitly requests a **vanilla** web application (No React, no Vue, no bundlers, no npm), apply these rules.
|
|
8
|
+
All output must be raw `.html`, `.css`, and `.js` files that open directly in a browser.
|
|
9
|
+
|
|
10
|
+
### File Structure
|
|
11
|
+
Prefer a single `index.html` unless explicitly told otherwise.
|
|
12
|
+
For medium complexity, split into:
|
|
13
|
+
```
|
|
14
|
+
<project_dir>/
|
|
15
|
+
index.html
|
|
16
|
+
style.css
|
|
17
|
+
script.js
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
### HTML Rules
|
|
21
|
+
1. Always use `<!DOCTYPE html>` and `<meta charset="UTF-8">`.
|
|
22
|
+
2. Load `<link rel="stylesheet" href="style.css">` in `<head>`.
|
|
23
|
+
3. Load `<script src="script.js" defer></script>` at the end of `<head>` (using `defer` ensures the DOM is ready before the script runs — never use inline `<script>` at the top of the body).
|
|
24
|
+
4. Give every interactive element a clear, unique `id` (e.g. `id="display"`, `id="btn-plus"`).
|
|
25
|
+
|
|
26
|
+
### JavaScript Rules
|
|
27
|
+
1. **Always wrap code in `DOMContentLoaded`** OR use the `defer` attribute on the script tag (both are acceptable; `defer` is preferred).
|
|
28
|
+
```js
|
|
29
|
+
// Option A: defer attribute on <script> — no wrapper needed
|
|
30
|
+
document.getElementById('btn').addEventListener('click', handler);
|
|
31
|
+
|
|
32
|
+
// Option B: if not using defer
|
|
33
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
34
|
+
document.getElementById('btn').addEventListener('click', handler);
|
|
35
|
+
});
|
|
36
|
+
```
|
|
37
|
+
2. Never use `var`; use `const` and `let`.
|
|
38
|
+
3. Never call `addEventListener` on a potentially `null` element. Always verify that `getElementById` returns a non-null value before using it, or use `defer`.
|
|
39
|
+
4. Keep state in plain variables or a small object — no frameworks.
|
|
40
|
+
5. For calculations that involve strings (e.g. calculator display values), always parse with `parseFloat()` or `parseInt()` before arithmetic.
|
|
41
|
+
|
|
42
|
+
### CSS Rules
|
|
43
|
+
1. Use CSS custom properties (`--color-primary`) in `:root` for all repeated values.
|
|
44
|
+
2. Use `box-sizing: border-box` globally: `*, *::before, *::after { box-sizing: border-box; }`.
|
|
45
|
+
3. Avoid `float`; use `flexbox` or `grid` for layout.
|
|
46
|
+
4. Button states: always define `:hover` and `:active` styles.
|
|
47
|
+
|
|
48
|
+
### Common Bugs to Avoid
|
|
49
|
+
- `Cannot read properties of null (reading 'addEventListener')` → script is running before the DOM is ready. Fix: use `defer` on `<script>` tag.
|
|
50
|
+
- Calculator buttons not working → check that button `id` values in HTML exactly match what `querySelector`/`getElementById` selects in JS.
|
|
51
|
+
- `file:` URL security errors in `<iframe>` → never embed the same page in an iframe.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
tags: opalacoder, cli, commands, help, list, clear, exit, quit, skills, addskill, rmskill, lsskills
|
|
2
|
+
description: MANDATORY IF THE USER TYPES: 'list', 'commands', 'help', 'clear', 'exit', 'quit', 'skills'. Instructions on how to guide the use of the OpalaCoder CLI.
|
|
3
|
+
---
|
|
4
|
+
You are an agent integrated into **OpalaCoder**, a terminal assistant and automated software engineering executor.
|
|
5
|
+
|
|
6
|
+
The user interacts with you via the terminal. Often the user might type words like `list`, `help`, `clear`
|
|
7
|
+
wanting to execute an OpalaCoder system command.
|
|
8
|
+
|
|
9
|
+
**Golden Rule:** All native OpalaCoder commands **must start with a slash (`/`)**. If the user asks to list
|
|
10
|
+
projects, ask for help, or clear memory and does not use the slash, guide them to use the correct command.
|
|
11
|
+
|
|
12
|
+
OpalaCoder works around **projects**. Each project has a name, a filesystem path, and a set of active skills.
|
|
13
|
+
All file operations and commands happen inside the active project's directory.
|
|
14
|
+
|
|
15
|
+
### Available Commands
|
|
16
|
+
|
|
17
|
+
| Command | Description |
|
|
18
|
+
|---|---|
|
|
19
|
+
| `/help` or `/h` | Show this command list |
|
|
20
|
+
| `/clear` | Clear the current project's conversation history and memory |
|
|
21
|
+
| `/rename <name>` | Rename the current project |
|
|
22
|
+
| `/list` | List all saved projects (name, path, last updated) |
|
|
23
|
+
| `/load <name>` | Load another project by its internal key name |
|
|
24
|
+
| `/delete <name>` | Delete a project and all its history |
|
|
25
|
+
| `/skills` | List ALL available skills; active ones are marked with * |
|
|
26
|
+
| `/lsskills` | List only the skills currently active in this project |
|
|
27
|
+
| `/addskill <name>` | Add a skill to this project |
|
|
28
|
+
| `/rmskill <name>` | Remove a skill from this project (cannot remove `opalacoder`) |
|
|
29
|
+
| `/exit` or `/quit` | Close OpalaCoder |
|
|
30
|
+
|
|
31
|
+
If the user types `list`, `help`, `clear`, `skills` without a slash, politely advise them
|
|
32
|
+
to use the slash-prefixed command (e.g. `/list`). Do not try to generate code or list fake files
|
|
33
|
+
to satisfy a word that was clearly meant to be a CLI command.
|
|
34
|
+
|
|
35
|
+
**Fallback Rule:** If the user types something that by itself makes no sense (like an isolated word,
|
|
36
|
+
a meaningless expression, or "none"), respond exactly with: "I didn't understand what you meant.
|
|
37
|
+
Would you like to see the OpalaCoder help options? (If so, type `/help`)" — translate to the user's language.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
tags: python, shell, bash, subprocess, script, command
|
|
2
|
+
description: Anti-hang protections for shell commands (subprocess) in Python scripts (devnull).
|
|
3
|
+
scope: orchestrator
|
|
4
|
+
---
|
|
5
|
+
Whenever you need to run shell commands from Python:
|
|
6
|
+
Use `import subprocess` and call the function passing `stdin=subprocess.DEVNULL`:
|
|
7
|
+
`subprocess.run(cmd, shell=True, capture_output=True, text=True, stdin=subprocess.DEVNULL)`
|
|
8
|
+
|
|
9
|
+
The `stdin=subprocess.DEVNULL` flag is MANDATORY. It prevents infinite hangs if any command tries to be interactive (asking for Y/N, passwords, or menu choices). The script must be 100% automated.
|
|
10
|
+
|
|
11
|
+
Print the `stdout` and `stderr` of each command so we can log and validate the execution result.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
tags: react, vite, npm, npx, node, javascript, js, ts, frontend
|
|
2
|
+
description: Use ONLY if the user asks to initialize, create, or configure a React or Vite project.
|
|
3
|
+
scope: orchestrator
|
|
4
|
+
---
|
|
5
|
+
If you need to initialize a Vite project using `npx create-vite`, you MUST use the 100% automated command passing the flags `-y` and `--template react` (or another template if explicitly requested).
|
|
6
|
+
Example: `npx -y create-vite@latest <project_name> --template react`
|
opalacoder/skills.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
# Skill scopes:
|
|
5
|
+
# "all" — injected everywhere: intent classifier AND orchestrator (default)
|
|
6
|
+
# "orchestrator" — injected ONLY into the planner/executor, NEVER into the intent classifier
|
|
7
|
+
# "classifier" — injected ONLY into the intent classifier
|
|
8
|
+
SCOPE_ALL = "all"
|
|
9
|
+
SCOPE_ORCHESTRATOR = "orchestrator"
|
|
10
|
+
SCOPE_CLASSIFIER = "classifier"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _skill_search_dirs(project_path: str = "") -> list[str]:
|
|
14
|
+
"""Return skill directories in priority order."""
|
|
15
|
+
dirs = []
|
|
16
|
+
# 1. Project's own skills dir
|
|
17
|
+
if project_path:
|
|
18
|
+
dirs.append(os.path.join(project_path, "skills"))
|
|
19
|
+
# 2. Package skills dir (when installed via wheel)
|
|
20
|
+
package_dir = os.path.dirname(os.path.abspath(__file__))
|
|
21
|
+
dirs.append(os.path.join(package_dir, "skills"))
|
|
22
|
+
# 3. Repo root skills dir (when running from source)
|
|
23
|
+
repo_root = os.path.dirname(package_dir)
|
|
24
|
+
dirs.append(os.path.join(repo_root, "skills"))
|
|
25
|
+
# 4. User global skills
|
|
26
|
+
dirs.append(os.path.expanduser("~/.opalacoder/skills"))
|
|
27
|
+
return dirs
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _parse_skill_file(filepath: str) -> dict | None:
|
|
31
|
+
try:
|
|
32
|
+
with open(filepath, "r", encoding="utf-8") as f:
|
|
33
|
+
content = f.read()
|
|
34
|
+
except Exception:
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
tags_match = re.search(r"^tags:\s*(.+)$", content, re.IGNORECASE | re.MULTILINE)
|
|
38
|
+
desc_match = re.search(r"^description:\s*(.+)$", content, re.IGNORECASE | re.MULTILINE)
|
|
39
|
+
scope_match = re.search(r"^scope:\s*(.+)$", content, re.IGNORECASE | re.MULTILINE)
|
|
40
|
+
|
|
41
|
+
tags = [t.strip().lower() for t in tags_match.group(1).split(",") if t.strip()] if tags_match else []
|
|
42
|
+
description = desc_match.group(1).strip() if desc_match else "No description"
|
|
43
|
+
scope = scope_match.group(1).strip().lower() if scope_match else SCOPE_ALL
|
|
44
|
+
|
|
45
|
+
clean_content = re.sub(r"^(tags|description|scope):\s*.+\n?", "", content, flags=re.IGNORECASE | re.MULTILINE).strip()
|
|
46
|
+
clean_content = re.sub(r"^---\n?", "", clean_content, flags=re.MULTILINE).strip()
|
|
47
|
+
|
|
48
|
+
name = os.path.basename(filepath).replace(".md", "")
|
|
49
|
+
return {"name": name, "description": description, "tags": tags, "scope": scope, "content": clean_content}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def load_skills(project_path: str = "") -> list[dict]:
|
|
53
|
+
"""Load all available skill files from the search dirs."""
|
|
54
|
+
skills = []
|
|
55
|
+
loaded_files: set[str] = set()
|
|
56
|
+
|
|
57
|
+
for s_dir in _skill_search_dirs(project_path):
|
|
58
|
+
if not os.path.isdir(s_dir):
|
|
59
|
+
continue
|
|
60
|
+
for filename in sorted(os.listdir(s_dir)):
|
|
61
|
+
if not filename.endswith(".md") or filename in loaded_files:
|
|
62
|
+
continue
|
|
63
|
+
skill = _parse_skill_file(os.path.join(s_dir, filename))
|
|
64
|
+
if skill:
|
|
65
|
+
skills.append(skill)
|
|
66
|
+
loaded_files.add(filename)
|
|
67
|
+
|
|
68
|
+
return skills
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def load_project_skills(project_path: str, skill_names: list[str]) -> list[dict]:
|
|
72
|
+
"""Load only the skills listed in the project's skill_names, always including opalacoder."""
|
|
73
|
+
names = set(skill_names)
|
|
74
|
+
names.add("opalacoder")
|
|
75
|
+
all_skills = load_skills(project_path)
|
|
76
|
+
return [s for s in all_skills if s["name"] in names]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def find_skill_file(skill_name: str, project_path: str = "") -> str | None:
|
|
80
|
+
"""Return the path to <skill_name>.md if found in any search dir, else None."""
|
|
81
|
+
filename = skill_name if skill_name.endswith(".md") else f"{skill_name}.md"
|
|
82
|
+
for s_dir in _skill_search_dirs(project_path):
|
|
83
|
+
candidate = os.path.join(s_dir, filename)
|
|
84
|
+
if os.path.isfile(candidate):
|
|
85
|
+
return candidate
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
async def select_skills_for_project(model: str, description: str, project_path: str = "") -> list[str]:
|
|
90
|
+
"""Use an LLM to pick skills relevant to a new project description. Always includes opalacoder."""
|
|
91
|
+
all_skills = load_skills(project_path)
|
|
92
|
+
catalog = "\n".join(f"- {s['name']}: {s['description']}" for s in all_skills if s['name'] != "opalacoder")
|
|
93
|
+
if not catalog:
|
|
94
|
+
return ["opalacoder"]
|
|
95
|
+
|
|
96
|
+
prompt = (
|
|
97
|
+
f"PROJECT DESCRIPTION: {description}\n\n"
|
|
98
|
+
f"AVAILABLE SKILLS:\n{catalog}\n\n"
|
|
99
|
+
"List the skill names (comma-separated) that are relevant to this project. "
|
|
100
|
+
"Reply with skill names only, nothing else."
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
from .agents import make_skill_selector
|
|
104
|
+
from agenticblocks.blocks.llm.agent import AgentInput
|
|
105
|
+
|
|
106
|
+
selector = make_skill_selector(model)
|
|
107
|
+
try:
|
|
108
|
+
result = await selector.run(AgentInput(prompt=prompt))
|
|
109
|
+
selected = [w.strip().lower() for w in result.response.replace("\n", ",").split(",") if w.strip()]
|
|
110
|
+
valid_names = {s["name"].lower(): s["name"] for s in all_skills}
|
|
111
|
+
chosen = ["opalacoder"] + [valid_names[n] for n in selected if n in valid_names and valid_names[n] != "opalacoder"]
|
|
112
|
+
return chosen if chosen else ["opalacoder"]
|
|
113
|
+
except Exception:
|
|
114
|
+
return ["opalacoder"]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _filter_by_scope(skills: list, target_scope: str) -> list:
|
|
118
|
+
"""Return only skills that are allowed in the given target context."""
|
|
119
|
+
if target_scope == SCOPE_CLASSIFIER:
|
|
120
|
+
# Classifier gets: scope=all and scope=classifier
|
|
121
|
+
return [s for s in skills if s["scope"] in (SCOPE_ALL, SCOPE_CLASSIFIER)]
|
|
122
|
+
elif target_scope == SCOPE_ORCHESTRATOR:
|
|
123
|
+
# Orchestrator gets: scope=all and scope=orchestrator
|
|
124
|
+
return [s for s in skills if s["scope"] in (SCOPE_ALL, SCOPE_ORCHESTRATOR)]
|
|
125
|
+
# Fallback: return all
|
|
126
|
+
return skills
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def get_relevant_skills(text: str, scope: str = SCOPE_ALL, project_skills: list[dict] = None) -> str:
|
|
130
|
+
"""Keyword-matching skill selector. Uses project_skills if provided."""
|
|
131
|
+
skills = _filter_by_scope(project_skills if project_skills is not None else load_skills(), scope)
|
|
132
|
+
if not skills:
|
|
133
|
+
return ""
|
|
134
|
+
|
|
135
|
+
text_lower = text.lower()
|
|
136
|
+
words = set(re.findall(r'\b\w+\b', text_lower))
|
|
137
|
+
injected_contents = []
|
|
138
|
+
|
|
139
|
+
for skill in skills:
|
|
140
|
+
for tag in skill["tags"]:
|
|
141
|
+
if (tag in words) or (" " in tag and tag in text_lower):
|
|
142
|
+
injected_contents.append(f"--- REFERENCE MATERIAL: {skill['name']} (Use ONLY if applicable to the task) ---\n{skill['content']}")
|
|
143
|
+
break
|
|
144
|
+
|
|
145
|
+
if not injected_contents:
|
|
146
|
+
return ""
|
|
147
|
+
|
|
148
|
+
return "\nOPTIONAL BEST PRACTICES / REFERENCE:\n" + "\n\n".join(injected_contents)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
async def get_relevant_skills_llm(model: str, request: str, scope: str = SCOPE_ALL, project_skills: list[dict] = None) -> str:
|
|
152
|
+
"""LLM-based semantic skill selector. Uses project_skills if provided."""
|
|
153
|
+
skills = _filter_by_scope(project_skills if project_skills is not None else load_skills(), scope)
|
|
154
|
+
if not skills:
|
|
155
|
+
return ""
|
|
156
|
+
|
|
157
|
+
skills_catalog = "\n".join([f"- {s['name']}: {s['description']}" for s in skills])
|
|
158
|
+
prompt = f"USER REQUEST: {request}\n\nAVAILABLE SKILLS:\n{skills_catalog}"
|
|
159
|
+
|
|
160
|
+
from .agents import make_skill_selector
|
|
161
|
+
from agenticblocks.blocks.llm.agent import AgentInput
|
|
162
|
+
from . import terminal as T
|
|
163
|
+
|
|
164
|
+
T.thinking("Selecting skill context (Semantic Router)...")
|
|
165
|
+
selector = make_skill_selector(model)
|
|
166
|
+
try:
|
|
167
|
+
result = await selector.run(AgentInput(prompt=prompt))
|
|
168
|
+
selected_text = result.response.lower()
|
|
169
|
+
except Exception as e:
|
|
170
|
+
T.error(f"Skill router error: {e}")
|
|
171
|
+
return ""
|
|
172
|
+
|
|
173
|
+
injected_contents = []
|
|
174
|
+
selected_skill_names = []
|
|
175
|
+
for skill in skills:
|
|
176
|
+
if skill["name"].lower() in selected_text:
|
|
177
|
+
injected_contents.append(f"--- REFERENCE MATERIAL: {skill['name']} (Use ONLY if applicable to the task) ---\n{skill['content']}")
|
|
178
|
+
selected_skill_names.append(skill["name"])
|
|
179
|
+
|
|
180
|
+
if not injected_contents:
|
|
181
|
+
return ""
|
|
182
|
+
|
|
183
|
+
T.info(f"Active skills: {', '.join(selected_skill_names)}")
|
|
184
|
+
return "\nOPTIONAL BEST PRACTICES / REFERENCE:\n" + "\n\n".join(injected_contents)
|
opalacoder/structured.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Structured LLM output using instructor + Pydantic.
|
|
2
|
+
|
|
3
|
+
All LLM calls that must return structured data go through here.
|
|
4
|
+
instructor automatically retries with validation error feedback when the
|
|
5
|
+
model produces malformed JSON, making this robust for small models.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import instructor
|
|
9
|
+
import litellm
|
|
10
|
+
from pydantic import BaseModel, Field, field_validator
|
|
11
|
+
|
|
12
|
+
# MD_JSON works with any model: asks for JSON inside a markdown block,
|
|
13
|
+
# no native tool-calling support required (safe for local/small models).
|
|
14
|
+
_client = instructor.from_litellm(litellm.acompletion, mode=instructor.Mode.MD_JSON)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ─── Schemas ──────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
class SubplanSchema(BaseModel):
|
|
20
|
+
id: str = Field(description="Unique ID in format SP-<n>, e.g. SP-1")
|
|
21
|
+
phase: str = Field(description="Short phase name")
|
|
22
|
+
objective: str = Field(description="What this subplan delivers")
|
|
23
|
+
prerequisites: list[str] = Field(
|
|
24
|
+
default_factory=list,
|
|
25
|
+
description="List of prerequisite subplan IDs, or empty.",
|
|
26
|
+
)
|
|
27
|
+
steps: list[str] = Field(description="Concrete atomic actions, max 5 items")
|
|
28
|
+
completion_criterion: str = Field(description="How to validate completion")
|
|
29
|
+
|
|
30
|
+
@field_validator("steps")
|
|
31
|
+
@classmethod
|
|
32
|
+
def cap_steps(cls, v: list[str]) -> list[str]:
|
|
33
|
+
return v[:5]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class DecompositionResult(BaseModel):
|
|
37
|
+
subplans: list[SubplanSchema] = Field(
|
|
38
|
+
description="Lista ordenada de subplanos executáveis derivados do panorama"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ConfirmationResult(BaseModel):
|
|
43
|
+
approved: bool = Field(
|
|
44
|
+
description="True se o usuário aprovou o plano, False se quer alterações"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ─── Callers ──────────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
_DECOMPOSE_SYSTEM = """You are a plan decomposition agent.
|
|
51
|
+
Break the given PANORAMA into sequential executable subplans.
|
|
52
|
+
|
|
53
|
+
Rules:
|
|
54
|
+
- Each subplan runs as a standalone Python script (no human input).
|
|
55
|
+
- IDs must be sequential: SP-1, SP-2, SP-3, ...
|
|
56
|
+
- Max 5 steps per subplan.
|
|
57
|
+
- Never create subplans for analysis or planning; only for code execution.
|
|
58
|
+
Output valid JSON only. DO NOT output any explanation or trailing text."""
|
|
59
|
+
|
|
60
|
+
_CONFIRM_SYSTEM = """Determine if the user APPROVED the plan or wants changes.
|
|
61
|
+
Return approved=true only for clear unconditional approval (e.g. "yes", "ok", "proceed").
|
|
62
|
+
Return approved=false for any change request, however polite (e.g. "add", "remove", "change").
|
|
63
|
+
Output valid JSON only. DO NOT output any explanation or trailing text."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def decompose_to_subplans(
|
|
67
|
+
plan_text: str,
|
|
68
|
+
model: str,
|
|
69
|
+
max_retries: int = 3,
|
|
70
|
+
timeout: int = 120,
|
|
71
|
+
) -> DecompositionResult:
|
|
72
|
+
"""
|
|
73
|
+
Ask the LLM to decompose a plan into structured subplans.
|
|
74
|
+
Uses MD_JSON mode — works with any model, including local/small ones.
|
|
75
|
+
instructor retries automatically with validation feedback on bad output.
|
|
76
|
+
"""
|
|
77
|
+
return await _client.chat.completions.create(
|
|
78
|
+
model=model,
|
|
79
|
+
messages=[
|
|
80
|
+
{"role": "system", "content": _DECOMPOSE_SYSTEM},
|
|
81
|
+
{"role": "user", "content": f"PANORAMA:\n{plan_text}"},
|
|
82
|
+
],
|
|
83
|
+
response_model=DecompositionResult,
|
|
84
|
+
max_retries=max_retries,
|
|
85
|
+
timeout=timeout,
|
|
86
|
+
max_tokens=4096,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def confirm_plan(
|
|
91
|
+
plan_text: str,
|
|
92
|
+
user_response: str,
|
|
93
|
+
model: str,
|
|
94
|
+
max_retries: int = 3,
|
|
95
|
+
timeout: int = 60,
|
|
96
|
+
) -> ConfirmationResult:
|
|
97
|
+
"""
|
|
98
|
+
Ask the LLM to classify whether the user approved the plan.
|
|
99
|
+
Uses MD_JSON mode — works with any model, including local/small ones.
|
|
100
|
+
"""
|
|
101
|
+
return await _client.chat.completions.create(
|
|
102
|
+
model=model,
|
|
103
|
+
messages=[
|
|
104
|
+
{"role": "system", "content": _CONFIRM_SYSTEM},
|
|
105
|
+
{
|
|
106
|
+
"role": "user",
|
|
107
|
+
"content": f"PLANO:\n{plan_text}\n\nRESPOSTA DO USUÁRIO:\n{user_response}",
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
response_model=ConfirmationResult,
|
|
111
|
+
max_retries=max_retries,
|
|
112
|
+
timeout=timeout,
|
|
113
|
+
)
|