nightfall-cli 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.
- nightfall/__init__.py +0 -0
- nightfall/agent.py +88 -0
- nightfall/commands.py +78 -0
- nightfall/compact.py +132 -0
- nightfall/config.py +21 -0
- nightfall/context.py +77 -0
- nightfall/history.py +127 -0
- nightfall/llm.py +90 -0
- nightfall/permissions.py +120 -0
- nightfall/prompt.py +61 -0
- nightfall/sandbox.py +210 -0
- nightfall/session.py +86 -0
- nightfall/skills.py +34 -0
- nightfall/subagent.py +166 -0
- nightfall/todos.py +66 -0
- nightfall/tools.py +198 -0
- nightfall/ui.py +290 -0
- nightfall_cli-0.1.0.dist-info/METADATA +58 -0
- nightfall_cli-0.1.0.dist-info/RECORD +21 -0
- nightfall_cli-0.1.0.dist-info/WHEEL +4 -0
- nightfall_cli-0.1.0.dist-info/entry_points.txt +2 -0
nightfall/todos.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""The plan. Lives here, not in the transcript, and is re-injected every turn."""
|
|
2
|
+
|
|
3
|
+
MARKS = {"pending": "[ ]", "in_progress": "[~]", "done": "[x]"}
|
|
4
|
+
|
|
5
|
+
TODOS = [] # [{"content": ..., "activeForm": ..., "status": ...}]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def write_todos(todos):
|
|
9
|
+
"""Replace the whole list. Exactly one task may be in_progress."""
|
|
10
|
+
active = [t for t in todos if t["status"] == "in_progress"]
|
|
11
|
+
if len(active) > 1:
|
|
12
|
+
return f"Error: {len(active)} tasks are in_progress. Only one may be."
|
|
13
|
+
|
|
14
|
+
TODOS[:] = todos
|
|
15
|
+
return todos_prompt() or "Todo list cleared."
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def todos_prompt():
|
|
19
|
+
return "\n".join(f"{MARKS[t['status']]} {t['content']}" for t in TODOS)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def active_form():
|
|
23
|
+
"""What the agent is doing right now, for the spinner."""
|
|
24
|
+
for todo in TODOS:
|
|
25
|
+
if todo["status"] == "in_progress":
|
|
26
|
+
return todo["activeForm"]
|
|
27
|
+
return "thinking"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
TODO_SCHEMA = {
|
|
31
|
+
"type": "function",
|
|
32
|
+
"function": {
|
|
33
|
+
"name": "write_todos",
|
|
34
|
+
"description": (
|
|
35
|
+
"Record the plan for a multi-step task. Send the whole list every "
|
|
36
|
+
"time. Keep exactly one task in_progress and update it as you go."
|
|
37
|
+
),
|
|
38
|
+
"parameters": {
|
|
39
|
+
"type": "object",
|
|
40
|
+
"properties": {
|
|
41
|
+
"todos": {
|
|
42
|
+
"type": "array",
|
|
43
|
+
"items": {
|
|
44
|
+
"type": "object",
|
|
45
|
+
"properties": {
|
|
46
|
+
"content": {
|
|
47
|
+
"type": "string",
|
|
48
|
+
"description": "The task, imperative: 'Fix the parser'",
|
|
49
|
+
},
|
|
50
|
+
"activeForm": {
|
|
51
|
+
"type": "string",
|
|
52
|
+
"description": "Present continuous: 'Fixing the parser'",
|
|
53
|
+
},
|
|
54
|
+
"status": {
|
|
55
|
+
"type": "string",
|
|
56
|
+
"enum": ["pending", "in_progress", "done"],
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
"required": ["content", "activeForm", "status"],
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
"required": ["todos"],
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
}
|
nightfall/tools.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import subprocess
|
|
3
|
+
|
|
4
|
+
from . import history
|
|
5
|
+
from . import sandbox
|
|
6
|
+
from .permissions import check
|
|
7
|
+
from .subagent import TASK_SCHEMA, task
|
|
8
|
+
from .skills import read_skill
|
|
9
|
+
from .todos import TODO_SCHEMA, write_todos
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def bash(command: str) -> str:
|
|
13
|
+
"""Run a shell command and return its combined stdout and stderr."""
|
|
14
|
+
try:
|
|
15
|
+
result = sandbox.run(command)
|
|
16
|
+
except subprocess.TimeoutExpired as expired:
|
|
17
|
+
# Hand the failure back as a result. A slow command is the model's
|
|
18
|
+
# problem to work around, not a reason to take the session down.
|
|
19
|
+
return (
|
|
20
|
+
f"Timed out after {expired.timeout}s and was killed. "
|
|
21
|
+
"Narrow it down - search inside the working directory rather than /."
|
|
22
|
+
)
|
|
23
|
+
return history.cap((result.stdout + result.stderr) or "(no output)")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def read_file(path: str) -> str:
|
|
27
|
+
"""Read a file and return its contents."""
|
|
28
|
+
with open(path) as f:
|
|
29
|
+
return history.cap(f.read())
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def write_file(path: str, content: str) -> str:
|
|
33
|
+
"""Create a file, or overwrite it if it already exists."""
|
|
34
|
+
with open(path, "w") as f:
|
|
35
|
+
f.write(content)
|
|
36
|
+
return f"Wrote {path}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def str_replace(path, old_str, new_str, allow_multi_edit=False):
|
|
40
|
+
"""Swap exact text in a file. old_str must match exactly once."""
|
|
41
|
+
with open(path) as f:
|
|
42
|
+
content = f.read()
|
|
43
|
+
|
|
44
|
+
count = content.count(old_str)
|
|
45
|
+
if count == 0:
|
|
46
|
+
return f"Error: old_str was not found in {path}"
|
|
47
|
+
if count > 1 and not allow_multi_edit:
|
|
48
|
+
return (
|
|
49
|
+
f"Error: old_str matches {count} times in {path}. "
|
|
50
|
+
"Add surrounding lines to make it unique, "
|
|
51
|
+
"or set allow_multi_edit to replace them all."
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
with open(path, "w") as f:
|
|
55
|
+
f.write(content.replace(old_str, new_str))
|
|
56
|
+
return f"Replaced {count} match(es) in {path}"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def execute(tool_call):
|
|
60
|
+
"""Run one tool call through the permission layer.
|
|
61
|
+
|
|
62
|
+
Shared by the main loop and by subagents, so a subagent is fenced in by
|
|
63
|
+
exactly the same rules - it is not a way around them.
|
|
64
|
+
|
|
65
|
+
A tool call is text the model wrote, so all of it is untrusted: the name
|
|
66
|
+
may not exist, the arguments may not be JSON, and they may not match the
|
|
67
|
+
signature. Every one of those comes back as a result the model can read
|
|
68
|
+
and retry. None of them is allowed to end the session.
|
|
69
|
+
"""
|
|
70
|
+
from .ui import ui
|
|
71
|
+
|
|
72
|
+
name = tool_call.function.name
|
|
73
|
+
try:
|
|
74
|
+
args = json.loads(tool_call.function.arguments)
|
|
75
|
+
except json.JSONDecodeError as broken:
|
|
76
|
+
return {}, f"Error: arguments were not valid JSON ({broken})."
|
|
77
|
+
|
|
78
|
+
if name not in TOOLS:
|
|
79
|
+
return args, f"Error: no tool named '{name}'. Available: {', '.join(TOOLS)}."
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
action, reason = check(name, args)
|
|
83
|
+
if action == "deny":
|
|
84
|
+
return args, f"Blocked by policy: {reason}"
|
|
85
|
+
if action == "ask" and not ui.approve(reason):
|
|
86
|
+
return args, "The user denied this tool call."
|
|
87
|
+
return args, TOOLS[name](**args)
|
|
88
|
+
except TypeError as mismatch:
|
|
89
|
+
return args, f"Error: wrong arguments for {name} ({mismatch})."
|
|
90
|
+
except KeyError as missing:
|
|
91
|
+
return args, f"Error: {name} needs an argument you did not send: {missing}."
|
|
92
|
+
except Exception as failure: # noqa: BLE001 - the model gets to see and retry
|
|
93
|
+
return args, f"Error: {name} failed - {type(failure).__name__}: {failure}"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
TOOL_SCHEMAS = [
|
|
97
|
+
{
|
|
98
|
+
"type": "function",
|
|
99
|
+
"function": {
|
|
100
|
+
"name": "bash",
|
|
101
|
+
"description": "Run a shell command and return its combined stdout and stderr.",
|
|
102
|
+
"parameters": {
|
|
103
|
+
"type": "object",
|
|
104
|
+
"properties": {
|
|
105
|
+
"command": {
|
|
106
|
+
"type": "string",
|
|
107
|
+
"description": "The shell command to run",
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
"required": ["command"],
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
"type": "function",
|
|
116
|
+
"function": {
|
|
117
|
+
"name": "read_file",
|
|
118
|
+
"description": "Read a file and return its contents.",
|
|
119
|
+
"parameters": {
|
|
120
|
+
"type": "object",
|
|
121
|
+
"properties": {
|
|
122
|
+
"path": {
|
|
123
|
+
"type": "string",
|
|
124
|
+
"description": "Path to the file to read",
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
"required": ["path"],
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
"type": "function",
|
|
133
|
+
"function": {
|
|
134
|
+
"name": "read_skill",
|
|
135
|
+
"description": "Open a skill by name and return its full instructions.",
|
|
136
|
+
"parameters": {
|
|
137
|
+
"type": "object",
|
|
138
|
+
"properties": {
|
|
139
|
+
"name": {
|
|
140
|
+
"type": "string",
|
|
141
|
+
"description": "Name of the skill to open",
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
"required": ["name"],
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
"type": "function",
|
|
150
|
+
"function": {
|
|
151
|
+
"name": "write_file",
|
|
152
|
+
"description": "Create a file, or overwrite it if it already exists.",
|
|
153
|
+
"parameters": {
|
|
154
|
+
"type": "object",
|
|
155
|
+
"properties": {
|
|
156
|
+
"path": {"type": "string", "description": "File to write"},
|
|
157
|
+
"content": {"type": "string", "description": "The full contents"},
|
|
158
|
+
},
|
|
159
|
+
"required": ["path", "content"],
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
"type": "function",
|
|
165
|
+
"function": {
|
|
166
|
+
"name": "str_replace",
|
|
167
|
+
"description": (
|
|
168
|
+
"Replace exact text in a file. old_str must appear exactly once, "
|
|
169
|
+
"so include surrounding lines if needed."
|
|
170
|
+
),
|
|
171
|
+
"parameters": {
|
|
172
|
+
"type": "object",
|
|
173
|
+
"properties": {
|
|
174
|
+
"path": {"type": "string", "description": "File to edit"},
|
|
175
|
+
"old_str": {"type": "string", "description": "Exact text to find"},
|
|
176
|
+
"new_str": {"type": "string", "description": "Text to put in its place"},
|
|
177
|
+
"allow_multi_edit": {
|
|
178
|
+
"type": "boolean",
|
|
179
|
+
"description": "Replace every match instead of failing",
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
"required": ["path", "old_str", "new_str"],
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
TODO_SCHEMA,
|
|
187
|
+
TASK_SCHEMA,
|
|
188
|
+
]
|
|
189
|
+
|
|
190
|
+
TOOLS = {
|
|
191
|
+
"bash": bash,
|
|
192
|
+
"read_file": read_file,
|
|
193
|
+
"write_file": write_file,
|
|
194
|
+
"str_replace": str_replace,
|
|
195
|
+
"read_skill": read_skill,
|
|
196
|
+
"write_todos": write_todos,
|
|
197
|
+
"task": task,
|
|
198
|
+
}
|
nightfall/ui.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""Terminal presentation layer.
|
|
2
|
+
|
|
3
|
+
Knows nothing about LLMs, providers or tools - it only receives plain strings
|
|
4
|
+
and dicts and decides how they look.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
from contextlib import contextmanager
|
|
9
|
+
|
|
10
|
+
from rich.console import Console, Group
|
|
11
|
+
from rich.json import JSON
|
|
12
|
+
from rich.markdown import Markdown
|
|
13
|
+
from rich.padding import Padding
|
|
14
|
+
from rich.panel import Panel
|
|
15
|
+
from rich.rule import Rule
|
|
16
|
+
from rich.table import Table
|
|
17
|
+
from rich.text import Text
|
|
18
|
+
|
|
19
|
+
from . import prompt
|
|
20
|
+
from .todos import MARKS
|
|
21
|
+
|
|
22
|
+
ACCENT = "#7aa2f7"
|
|
23
|
+
USER = "#9ece6a"
|
|
24
|
+
TOOL = "#e0af68"
|
|
25
|
+
MUTED = "#565f89"
|
|
26
|
+
|
|
27
|
+
MAX_TOOL_OUTPUT_LINES = 12
|
|
28
|
+
|
|
29
|
+
TODO_STYLES = {
|
|
30
|
+
"done": f"{MUTED} strike",
|
|
31
|
+
"in_progress": f"bold {ACCENT}",
|
|
32
|
+
"pending": MUTED,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class UI:
|
|
37
|
+
def __init__(self):
|
|
38
|
+
self.console = Console()
|
|
39
|
+
self._totals = {}
|
|
40
|
+
|
|
41
|
+
# ---------------------------------------------------------------- input
|
|
42
|
+
|
|
43
|
+
def banner(self, sandbox_name="none"):
|
|
44
|
+
self.console.print()
|
|
45
|
+
self.console.print(
|
|
46
|
+
Rule(Text(" coding agent ", style=f"bold {ACCENT}"), style=MUTED)
|
|
47
|
+
)
|
|
48
|
+
self.console.print(
|
|
49
|
+
Padding(Text(f"sandbox: {sandbox_name} · opt-enter for a newline · ctrl-d to exit", style=MUTED), (0, 0, 0, 2))
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def clear(self):
|
|
53
|
+
self.console.clear()
|
|
54
|
+
|
|
55
|
+
def resumed(self, messages, label="resumed"):
|
|
56
|
+
turns = sum(1 for m in messages if m["role"] == "user")
|
|
57
|
+
self.console.print(
|
|
58
|
+
Padding(
|
|
59
|
+
Text(f"{label} · {len(messages)} messages · {turns} turns", style=MUTED),
|
|
60
|
+
(0, 0, 0, 2),
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def replay(self, messages):
|
|
65
|
+
"""Redraw a loaded transcript so the screen matches the history."""
|
|
66
|
+
results = {m["tool_call_id"]: m["content"] for m in messages if m["role"] == "tool"}
|
|
67
|
+
for message in messages:
|
|
68
|
+
if message["role"] == "user":
|
|
69
|
+
self.user(message["content"])
|
|
70
|
+
elif message["role"] == "assistant":
|
|
71
|
+
if message.get("content"):
|
|
72
|
+
self.agent(message["content"])
|
|
73
|
+
for call in message.get("tool_calls") or []:
|
|
74
|
+
self.tool(
|
|
75
|
+
call["function"]["name"],
|
|
76
|
+
json.loads(call["function"]["arguments"]),
|
|
77
|
+
results.get(call["id"], ""),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def approve(self, reason):
|
|
81
|
+
self.console.print(Padding(Text(reason, style=f"bold {TOOL}"), (1, 0, 0, 2)))
|
|
82
|
+
try:
|
|
83
|
+
answer = prompt.read(" allow? (y/n)> ").strip()
|
|
84
|
+
except (EOFError, KeyboardInterrupt):
|
|
85
|
+
return False
|
|
86
|
+
return answer.lower().startswith("y")
|
|
87
|
+
|
|
88
|
+
def note(self, text):
|
|
89
|
+
self.console.print(Padding(Text(text, style=MUTED), (1, 0, 0, 2)))
|
|
90
|
+
|
|
91
|
+
def pick(self, title, rows):
|
|
92
|
+
"""Numbered list; returns the chosen index or None."""
|
|
93
|
+
self.console.print(Padding(Text(title, style=f"bold {ACCENT}"), (1, 0, 0, 2)))
|
|
94
|
+
for i, row in enumerate(rows):
|
|
95
|
+
self.console.print(Padding(Text(f"{i:>3} {row}", style=MUTED), (0, 0, 0, 2)))
|
|
96
|
+
try:
|
|
97
|
+
answer = prompt.read("\n number> ").strip()
|
|
98
|
+
except (EOFError, KeyboardInterrupt):
|
|
99
|
+
return None
|
|
100
|
+
return int(answer) if answer.isdigit() and int(answer) < len(rows) else None
|
|
101
|
+
|
|
102
|
+
def ask(self):
|
|
103
|
+
self.console.print()
|
|
104
|
+
try:
|
|
105
|
+
return prompt.read("> ").strip()
|
|
106
|
+
except (EOFError, KeyboardInterrupt):
|
|
107
|
+
self.console.print()
|
|
108
|
+
return ""
|
|
109
|
+
|
|
110
|
+
# --------------------------------------------------------------- output
|
|
111
|
+
|
|
112
|
+
def user(self, text):
|
|
113
|
+
self.console.print(
|
|
114
|
+
Padding(Text(text.strip(), style=f"bold {USER}"), (1, 0, 0, 2))
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def agent(self, text):
|
|
118
|
+
self.console.print(
|
|
119
|
+
Padding(
|
|
120
|
+
Group(
|
|
121
|
+
Text("agent", style=f"bold {ACCENT}"),
|
|
122
|
+
Padding(Markdown(text.strip()), (1, 0, 0, 0)),
|
|
123
|
+
),
|
|
124
|
+
(1, 2, 0, 2),
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def tool(self, name, args, result, nested=False):
|
|
129
|
+
if name == "write_todos" and args.get("todos"):
|
|
130
|
+
return self.todos(args["todos"])
|
|
131
|
+
|
|
132
|
+
header = Text.assemble(
|
|
133
|
+
(f"{name} ", f"bold {TOOL}"),
|
|
134
|
+
(self._format_args(args), MUTED),
|
|
135
|
+
)
|
|
136
|
+
self.console.print(
|
|
137
|
+
Padding(
|
|
138
|
+
Panel(
|
|
139
|
+
Group(header, Rule(style=MUTED), self._format_result(result)),
|
|
140
|
+
border_style=MUTED,
|
|
141
|
+
padding=(0, 1),
|
|
142
|
+
),
|
|
143
|
+
(1, 2, 0, 6 if nested else 2),
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def subagent(self, description):
|
|
148
|
+
"""Shown to you, never to the main agent - it only gets the report."""
|
|
149
|
+
self.console.print(
|
|
150
|
+
Padding(
|
|
151
|
+
Panel(
|
|
152
|
+
Text(description.strip(), style=MUTED),
|
|
153
|
+
title=Text("subagent · own context", style=f"bold {ACCENT}"),
|
|
154
|
+
title_align="left",
|
|
155
|
+
border_style=ACCENT,
|
|
156
|
+
padding=(0, 1),
|
|
157
|
+
),
|
|
158
|
+
(1, 2, 0, 4),
|
|
159
|
+
)
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
def injection(self, text):
|
|
163
|
+
self.console.print(
|
|
164
|
+
Padding(
|
|
165
|
+
Panel(
|
|
166
|
+
Text(text.strip(), style=MUTED),
|
|
167
|
+
title=Text("late injection", style=f"italic {MUTED}"),
|
|
168
|
+
title_align="left",
|
|
169
|
+
border_style=MUTED,
|
|
170
|
+
padding=(0, 1),
|
|
171
|
+
),
|
|
172
|
+
(1, 2, 0, 2),
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
def debug(self, data):
|
|
177
|
+
self.console.print(
|
|
178
|
+
Padding(
|
|
179
|
+
Panel(
|
|
180
|
+
JSON.from_data(data),
|
|
181
|
+
title=Text("raw response", style=f"italic {MUTED}"),
|
|
182
|
+
title_align="left",
|
|
183
|
+
border_style=TOOL,
|
|
184
|
+
padding=(0, 1),
|
|
185
|
+
),
|
|
186
|
+
(1, 2, 0, 2),
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
@contextmanager
|
|
191
|
+
def working(self, label="thinking"):
|
|
192
|
+
with self.console.status(
|
|
193
|
+
Text(label, style=MUTED), spinner="dots", spinner_style=ACCENT
|
|
194
|
+
):
|
|
195
|
+
yield
|
|
196
|
+
|
|
197
|
+
# ---------------------------------------------------------------- usage
|
|
198
|
+
|
|
199
|
+
def usage(self, stats):
|
|
200
|
+
for key, value in stats.items():
|
|
201
|
+
self._totals[key] = self._totals.get(key, 0) + (value or 0)
|
|
202
|
+
|
|
203
|
+
parts = " · ".join(
|
|
204
|
+
f"{value:,} {key.replace('_tokens', '')}"
|
|
205
|
+
for key, value in stats.items()
|
|
206
|
+
if value
|
|
207
|
+
)
|
|
208
|
+
self.console.print(Padding(Text(parts, style=MUTED), (1, 0, 0, 2)))
|
|
209
|
+
|
|
210
|
+
def summary(self):
|
|
211
|
+
if not self._totals:
|
|
212
|
+
return
|
|
213
|
+
|
|
214
|
+
table = Table.grid(padding=(0, 2))
|
|
215
|
+
table.add_column(style=MUTED)
|
|
216
|
+
table.add_column(style=f"bold {ACCENT}", justify="right")
|
|
217
|
+
for key, value in self._totals.items():
|
|
218
|
+
table.add_row(key.replace("_", " "), f"{value:,}")
|
|
219
|
+
|
|
220
|
+
self.console.print(Padding(table, (1, 2)))
|
|
221
|
+
self.console.print(Rule(style=MUTED))
|
|
222
|
+
self.console.print()
|
|
223
|
+
|
|
224
|
+
def compacted(self, before, messages):
|
|
225
|
+
summary = next(
|
|
226
|
+
(m["content"] for m in messages if "<summary>" in (m.get("content") or "")),
|
|
227
|
+
"",
|
|
228
|
+
)
|
|
229
|
+
self.console.print(
|
|
230
|
+
Padding(
|
|
231
|
+
Panel(
|
|
232
|
+
Markdown(summary.replace("<summary>", "").replace("</summary>", "")),
|
|
233
|
+
title=Text(
|
|
234
|
+
f"compacted · {before} → {len(messages)} messages",
|
|
235
|
+
style=f"bold {TOOL}",
|
|
236
|
+
),
|
|
237
|
+
title_align="left",
|
|
238
|
+
border_style=TOOL,
|
|
239
|
+
padding=(0, 1),
|
|
240
|
+
),
|
|
241
|
+
(1, 2, 0, 2),
|
|
242
|
+
)
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
def todos(self, todos):
|
|
246
|
+
"""The plan, as a checklist. The raw tool output is never worth showing."""
|
|
247
|
+
done = sum(1 for t in todos if t["status"] == "done")
|
|
248
|
+
|
|
249
|
+
rows = Table.grid(padding=(0, 1))
|
|
250
|
+
rows.add_column(no_wrap=True)
|
|
251
|
+
rows.add_column(overflow="fold")
|
|
252
|
+
for todo in todos:
|
|
253
|
+
status = todo["status"]
|
|
254
|
+
style = TODO_STYLES[status]
|
|
255
|
+
rows.add_row(
|
|
256
|
+
Text(MARKS[status], style=style),
|
|
257
|
+
Text(todo["content"], style=style),
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
self.console.print(
|
|
261
|
+
Padding(
|
|
262
|
+
Panel(
|
|
263
|
+
rows,
|
|
264
|
+
title=Text(f"todos {done}/{len(todos)}", style=f"bold {TOOL}"),
|
|
265
|
+
title_align="left",
|
|
266
|
+
border_style=MUTED,
|
|
267
|
+
padding=(0, 1),
|
|
268
|
+
),
|
|
269
|
+
(1, 2, 0, 2),
|
|
270
|
+
)
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
# -------------------------------------------------------------- helpers
|
|
274
|
+
|
|
275
|
+
def _format_args(self, args):
|
|
276
|
+
if len(args) == 1:
|
|
277
|
+
return str(next(iter(args.values())))
|
|
278
|
+
return json.dumps(args)
|
|
279
|
+
|
|
280
|
+
def _format_result(self, result):
|
|
281
|
+
lines = result.strip().splitlines() or ["(no output)"]
|
|
282
|
+
shown = lines[:MAX_TOOL_OUTPUT_LINES]
|
|
283
|
+
body = Text("\n".join(shown), style=MUTED)
|
|
284
|
+
hidden = len(lines) - len(shown)
|
|
285
|
+
if hidden > 0:
|
|
286
|
+
body.append(f"\n… {hidden} more lines", style=f"italic {TOOL}")
|
|
287
|
+
return body
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
ui = UI()
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: nightfall-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A minimal coding agent
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: openai>=3.5.0
|
|
7
|
+
Requires-Dist: prompt-toolkit>=3.0.53
|
|
8
|
+
Requires-Dist: pyyaml>=6.0.3
|
|
9
|
+
Requires-Dist: rich>=15.0.0
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# Nightfall CLI
|
|
13
|
+
|
|
14
|
+
A minimal coding agent harness in Python, built to show how the pieces of a coding agent fit together.
|
|
15
|
+
|
|
16
|
+
This is the Nightfall CLI repository, a small coding-agent harness built from scratch.
|
|
17
|
+
|
|
18
|
+
https://github.com/user-attachments/assets/e4aaa9e4-69ec-40e3-8f5a-e4ec8c5b7208
|
|
19
|
+
|
|
20
|
+
## Getting started
|
|
21
|
+
|
|
22
|
+
Install the project with [uv](https://docs.astral.sh/uv/):
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
uv sync
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Configure an OpenAI-compatible endpoint and key in `~/.agents/env`:
|
|
29
|
+
|
|
30
|
+
```text
|
|
31
|
+
BASE_URL=https://your-endpoint/v1
|
|
32
|
+
API_KEY=your-api-key
|
|
33
|
+
MODEL=your-model-name
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Start the agent from the repository root:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
uv run nightfall
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Use `uv run nightfall --resume` to resume the latest saved session or
|
|
43
|
+
`uv run nightfall --debug` to display raw model responses. The sandbox backend
|
|
44
|
+
is selected automatically for the host operating system.
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
## Features
|
|
48
|
+
|
|
49
|
+
- Interactive terminal chat
|
|
50
|
+
- Tools for running shell commands, reading and writing files, and making targeted edits.
|
|
51
|
+
- Configurable model and OpenAI-compatible API endpoint.
|
|
52
|
+
- Tool permissions and shell sandboxing on macOS, Linux, and Windows
|
|
53
|
+
- Skills loaded from project and user `.agents/skills` directories.
|
|
54
|
+
- Subagents for exploring a codebase in a separate context window.
|
|
55
|
+
- Todo tracking for tasks with multiple steps.
|
|
56
|
+
- Saved chat sessions, with `/sessions` to reopen them and `/rewind` to go back in the conversation.
|
|
57
|
+
- Automatic context compaction, plus `/compact` to trigger it manually.
|
|
58
|
+
- Git branch context and reminders when files change between turns.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
nightfall/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
nightfall/agent.py,sha256=Jx5tyhi9rOzC63MdUIfEIu44nu7dVVtNzOyYFisjJWQ,2543
|
|
3
|
+
nightfall/commands.py,sha256=D7eoBloTck9vs79nfCTm3R6fPOvjQ4tst15aj_2O0fM,2389
|
|
4
|
+
nightfall/compact.py,sha256=BQeGUoD4e7HpUtkiWZf8mFvvzz6lnLIVdvzkD6knnmI,4491
|
|
5
|
+
nightfall/config.py,sha256=goO7qzg46ZYA-7O8xcv5Wgc886W426s4yH043q0Zv4w,784
|
|
6
|
+
nightfall/context.py,sha256=ZMrYBpuhQP3LWIBNMCvuEegrkEvcsCJnJ3wumiLtQfA,2008
|
|
7
|
+
nightfall/history.py,sha256=C6C2JRHGctb1zfLEH-zNwK87jhhwN-VRYjl1a6ZIA-g,4449
|
|
8
|
+
nightfall/llm.py,sha256=T4ltsR2iS203xa7Kluy6476BhiK-VS4IKZzZJN_l1Nk,3118
|
|
9
|
+
nightfall/permissions.py,sha256=9M-PztNi_hoAlREndJTvxM0Y6_Q1XzdOWA3WQQ14324,3392
|
|
10
|
+
nightfall/prompt.py,sha256=6gLswlqAflBXaT8LKt3NBARD--I2mfQZSvMKwS1AW10,1848
|
|
11
|
+
nightfall/sandbox.py,sha256=1sC__he9GJKAG6QqSTeBozcMM_BMgg_-P1UzInOwNhI,7272
|
|
12
|
+
nightfall/session.py,sha256=qVmtDUZ9u2c3nBKyB163Oi309IMrojdgvCekwO-rhTE,2655
|
|
13
|
+
nightfall/skills.py,sha256=uzmiDk-sRFUGtwDXQMnaCByiR5mamKaGiq07UHdL8xs,942
|
|
14
|
+
nightfall/subagent.py,sha256=fDB5SzKOkv03LCccJuDw9-Q9K02DwkOpL0ulX4yUb_A,7134
|
|
15
|
+
nightfall/todos.py,sha256=iFmYI-x6mO_I52zIJnWMK8LeoEA3OYH69M190u56uKs,2238
|
|
16
|
+
nightfall/tools.py,sha256=Q_mLxDDWRlCUULaDlN7cs1UTLMQwJQES-SgwQNj1rzQ,6666
|
|
17
|
+
nightfall/ui.py,sha256=-pE-ajCbcWNbembgsbM-RurmModu5fTdpjQIqbhdj5k,9313
|
|
18
|
+
nightfall_cli-0.1.0.dist-info/METADATA,sha256=MF6GGIUUKal7Qn-DONKBCQnUjGh6VZEKFNdM8K5r4Vo,1790
|
|
19
|
+
nightfall_cli-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
20
|
+
nightfall_cli-0.1.0.dist-info/entry_points.txt,sha256=RrY9rZc2OVvwiUf3BDJ4AcGkiOt6mtN2uFRd2wxvH8c,51
|
|
21
|
+
nightfall_cli-0.1.0.dist-info/RECORD,,
|