nightfall-cli 0.1.0__tar.gz

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.
@@ -0,0 +1,37 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+ workflow_dispatch:
7
+
8
+ permissions:
9
+ contents: read
10
+ id-token: write
11
+
12
+ jobs:
13
+ publish:
14
+ runs-on: ubuntu-latest
15
+ environment: pypi
16
+
17
+ steps:
18
+ - name: Check out source
19
+ uses: actions/checkout@v4
20
+
21
+ - name: Set up Python
22
+ uses: actions/setup-python@v5
23
+ with:
24
+ python-version: "3.x"
25
+
26
+ - name: Build distributions
27
+ run: |
28
+ python -m pip install --upgrade build
29
+ python -m build
30
+
31
+ - name: Check distributions
32
+ run: |
33
+ python -m pip install --upgrade twine
34
+ python -m twine check dist/*
35
+
36
+ - name: Publish distributions to PyPI
37
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,10 @@
1
+ __pycache__/
2
+ *html
3
+ *.pyc
4
+ .venv/
5
+ .env
6
+ .envrc
7
+
8
+ # Local planning and architecture notes
9
+ docs/adr/
10
+ docs/plans/
@@ -0,0 +1 @@
1
+ 3.10
@@ -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,47 @@
1
+ # Nightfall CLI
2
+
3
+ A minimal coding agent harness in Python, built to show how the pieces of a coding agent fit together.
4
+
5
+ This is the Nightfall CLI repository, a small coding-agent harness built from scratch.
6
+
7
+ https://github.com/user-attachments/assets/e4aaa9e4-69ec-40e3-8f5a-e4ec8c5b7208
8
+
9
+ ## Getting started
10
+
11
+ Install the project with [uv](https://docs.astral.sh/uv/):
12
+
13
+ ```bash
14
+ uv sync
15
+ ```
16
+
17
+ Configure an OpenAI-compatible endpoint and key in `~/.agents/env`:
18
+
19
+ ```text
20
+ BASE_URL=https://your-endpoint/v1
21
+ API_KEY=your-api-key
22
+ MODEL=your-model-name
23
+ ```
24
+
25
+ Start the agent from the repository root:
26
+
27
+ ```bash
28
+ uv run nightfall
29
+ ```
30
+
31
+ Use `uv run nightfall --resume` to resume the latest saved session or
32
+ `uv run nightfall --debug` to display raw model responses. The sandbox backend
33
+ is selected automatically for the host operating system.
34
+
35
+
36
+ ## Features
37
+
38
+ - Interactive terminal chat
39
+ - Tools for running shell commands, reading and writing files, and making targeted edits.
40
+ - Configurable model and OpenAI-compatible API endpoint.
41
+ - Tool permissions and shell sandboxing on macOS, Linux, and Windows
42
+ - Skills loaded from project and user `.agents/skills` directories.
43
+ - Subagents for exploring a codebase in a separate context window.
44
+ - Todo tracking for tasks with multiple steps.
45
+ - Saved chat sessions, with `/sessions` to reopen them and `/rewind` to go back in the conversation.
46
+ - Automatic context compaction, plus `/compact` to trigger it manually.
47
+ - Git branch context and reminders when files change between turns.
File without changes
@@ -0,0 +1,88 @@
1
+ import argparse
2
+
3
+ from . import commands
4
+ from . import compact
5
+ from . import history
6
+ from . import session
7
+ from .context import reminder
8
+ from .llm import SYSTEM_PROMPT, call_llm
9
+ from . import sandbox
10
+ from .todos import active_form
11
+ from .tools import execute
12
+ from .ui import ui
13
+
14
+
15
+ def main():
16
+ parser = argparse.ArgumentParser()
17
+ parser.add_argument("--resume", action="store_true", help="continue the last session")
18
+ parser.add_argument("--debug", action="store_true", help="show the raw model response")
19
+ cli = parser.parse_args()
20
+
21
+ ui.banner(sandbox.name())
22
+
23
+ messages = [{"role": "system", "content": SYSTEM_PROMPT}]
24
+ if cli.resume:
25
+ saved = session.all_sessions()
26
+ if saved:
27
+ messages = session.open_session(saved[0]["id"])
28
+ history.strip(messages)
29
+ ui.resumed(messages)
30
+ ui.replay(messages)
31
+
32
+ while True:
33
+ user_input = ui.ask()
34
+ if not user_input:
35
+ break
36
+
37
+ if user_input.startswith("/"):
38
+ messages = commands.handle(user_input, messages)
39
+ session.save(messages)
40
+ continue
41
+
42
+ messages.append({"role": "user", "content": user_input})
43
+
44
+ while True:
45
+ injection = reminder()
46
+ ui.injection(injection["content"])
47
+
48
+ if history.fit(messages):
49
+ ui.note("dropped old tool output to make this request fit")
50
+
51
+ with ui.working(active_form()):
52
+ message, usage = call_llm(messages + [injection])
53
+
54
+ messages.append(message.model_dump(exclude_none=True))
55
+ session.save(messages)
56
+ ui.usage(usage)
57
+
58
+ if cli.debug:
59
+ ui.debug(message.model_dump(exclude_none=True))
60
+
61
+ if message.content:
62
+ ui.agent(message.content)
63
+
64
+ if not message.tool_calls:
65
+ break
66
+
67
+ for tool_call in message.tool_calls:
68
+ args, result = execute(tool_call)
69
+ ui.tool(tool_call.function.name, args, result)
70
+
71
+ messages.append({
72
+ "role": "tool",
73
+ "tool_call_id": tool_call.id,
74
+ "content": result,
75
+ })
76
+ session.save(messages)
77
+
78
+ history.sweep() # the turn is over: bin its temp files
79
+ history.strip(messages) # ...and shrink the tool output it produced
80
+
81
+ if compact.needed(usage):
82
+ messages = commands.compact(messages)
83
+
84
+ ui.summary()
85
+
86
+
87
+ if __name__ == "__main__":
88
+ main()
@@ -0,0 +1,78 @@
1
+ """Slash commands. Anything typed starting with / lands here."""
2
+
3
+ from . import compact as compaction
4
+ from . import sandbox
5
+ from . import session
6
+ from .ui import ui
7
+
8
+ COMMANDS = {
9
+ "/rewind": "jump back to an earlier point in this chat",
10
+ "/sessions": "open a past chat",
11
+ "/compact": "summarise the history so far and free up the context window",
12
+ }
13
+
14
+
15
+ def preview(message):
16
+ if message.get("tool_calls"):
17
+ return "-> " + message["tool_calls"][0]["function"]["name"]
18
+ return " ".join(str(message.get("content") or "").split())[:70]
19
+
20
+
21
+ def redraw(messages, label):
22
+ """The screen no longer matches the history, so wipe it and draw again."""
23
+ ui.clear()
24
+ ui.banner(sandbox.name())
25
+ ui.resumed(messages, label)
26
+ ui.replay(messages)
27
+ return messages
28
+
29
+
30
+ def rewind(messages):
31
+ rows = [f"{m['role']:<9} {preview(m)}" for m in messages]
32
+ choice = ui.pick("rewind to", rows)
33
+ if choice is None:
34
+ return messages
35
+ session.rewind_to(choice + 1)
36
+ return redraw(messages[: choice + 1], "rewound")
37
+
38
+
39
+ def sessions(messages):
40
+ saved = session.all_sessions()
41
+ if not saved:
42
+ ui.note("no saved chats yet")
43
+ return messages
44
+ rows = [f"{s['id']} {s['title']}" for s in saved]
45
+ choice = ui.pick("open chat", rows)
46
+ if choice is None:
47
+ return messages
48
+
49
+ return redraw(session.open_session(saved[choice]["id"]), "opened")
50
+
51
+
52
+ def compact(messages):
53
+ before = len(messages)
54
+ try:
55
+ with ui.working("compacting"):
56
+ compacted = compaction.compact(messages)
57
+ except Exception as failure: # noqa: BLE001
58
+ # Compaction is one more API call, and it fires when the window is
59
+ # nearly full - the worst moment to lose the session over a rate limit.
60
+ ui.note(f"compaction failed ({type(failure).__name__}); transcript kept as is")
61
+ return messages
62
+ if len(compacted) == before:
63
+ ui.note("nothing old enough to compact yet")
64
+ return messages
65
+ session.compacted(compacted)
66
+ ui.compacted(before, compacted)
67
+ return compacted
68
+
69
+
70
+ def handle(command, messages):
71
+ if command == "/compact":
72
+ return compact(messages)
73
+ if command == "/rewind":
74
+ return rewind(messages)
75
+ if command == "/sessions":
76
+ return sessions(messages)
77
+ ui.note("\n".join(f"{name} - {help}" for name, help in COMMANDS.items()))
78
+ return messages
@@ -0,0 +1,132 @@
1
+ """The compaction agent.
2
+
3
+ A second agent with one job: read a transcript that has grown too big and
4
+ write the handoff note a fresh agent would need to carry on.
5
+
6
+ The result replaces the messages it summarised, so this is the only place in
7
+ the codebase that throws information away for good. It runs rarely and cuts
8
+ deep - trimming just enough to fit would put us back over the line next turn,
9
+ and every trim costs the whole prompt cache.
10
+ """
11
+
12
+ from . import config
13
+ from .history import estimate, strip
14
+ from .llm import client
15
+
16
+ SYSTEM_PROMPT = """
17
+ You are compacting the transcript of a coding session. The session is out of
18
+ context window. Write the handoff note that lets a fresh agent pick the work up
19
+ without re-reading anything.
20
+
21
+ Use these sections, in this order. Skip any that would be empty.
22
+
23
+ ## Goal
24
+ What the user asked for. Quote them where the exact wording matters.
25
+
26
+ ## What happened
27
+ Decisions taken and the reasoning behind them. Include approaches that were
28
+ tried and abandoned, and why - those are the expensive lessons, and an agent
29
+ without them will try the same dead end again.
30
+
31
+ ## Files
32
+ Every file touched: path, and what changed in it.
33
+
34
+ ## State
35
+ What works, what is broken, what was left half-finished.
36
+
37
+ ## Next
38
+ The immediate next step.
39
+
40
+ Rules:
41
+ - Be specific. Real paths, function names, error text, exact commands.
42
+ - Keep anything the user explicitly asked for, corrected, or rejected.
43
+ - Never invent progress. If something was not finished, say it was not.
44
+ - No preamble and no sign-off. Start at the first heading.
45
+ """
46
+
47
+ HANDOFF = """<summary>
48
+ Everything before this point has been compacted out of the context window to
49
+ free up room. This is the record of it - treat it as your own memory of the
50
+ work so far, not as something the user told you.
51
+
52
+ {summary}
53
+ </summary>"""
54
+
55
+ def needed(usage):
56
+ """Has the last request grown past the point where we rebuild?"""
57
+ return usage["prompt_tokens"] > config.CONTEXT_WINDOW * config.COMPACT_AT
58
+
59
+
60
+ ROLES = {"user": "USER", "assistant": "ASSISTANT", "tool": "TOOL RESULT"}
61
+
62
+
63
+ def render(messages):
64
+ """Flatten the transcript into something the summariser can read."""
65
+ lines = []
66
+ for message in messages:
67
+ if message["role"] == "system":
68
+ continue
69
+
70
+ content = message.get("content") or ""
71
+ for call in message.get("tool_calls") or []:
72
+ function = call["function"]
73
+ content += f"\n[called {function['name']}: {function['arguments']}]"
74
+
75
+ lines.append(f"{ROLES.get(message['role'], message['role'])}: {content}")
76
+ return "\n\n".join(lines)
77
+
78
+
79
+ def summarize(messages):
80
+ """One LLM call, no tools. Returns the handoff note."""
81
+ response = client.chat.completions.create(
82
+ model=config.MODEL,
83
+ messages=[
84
+ {"role": "system", "content": SYSTEM_PROMPT},
85
+ {"role": "user", "content": render(messages)},
86
+ ],
87
+ )
88
+ return response.choices[0].message.content
89
+
90
+
91
+ def safe_boundary(messages, start):
92
+ """First index at or after `start` where cutting cannot orphan a tool call.
93
+
94
+ A tool result has to keep the assistant message that asked for it, so the
95
+ only safe cut points are the messages that open a fresh exchange.
96
+ """
97
+ for index in range(max(start, 1), len(messages)):
98
+ previous = messages[index - 1]
99
+ if messages[index]["role"] == "tool" or previous.get("tool_calls"):
100
+ continue
101
+ return index
102
+ return len(messages)
103
+
104
+
105
+ def tail_start(messages, budget):
106
+ """Walk back from the end, taking messages until the tail fills `budget`."""
107
+ total = 0
108
+ for index in range(len(messages) - 1, 0, -1):
109
+ total += estimate([messages[index]])
110
+ if total > budget:
111
+ return safe_boundary(messages, index)
112
+ return safe_boundary(messages, 1)
113
+
114
+
115
+ def compact(messages):
116
+ """system + summary + a recent tail. The caller freezes what comes back."""
117
+ cut = tail_start(messages, config.CONTEXT_WINDOW * config.COMPACT_TO)
118
+ if cut <= 1:
119
+ return messages # nothing old enough to be worth summarising
120
+
121
+ summary = summarize(messages[1:cut])
122
+ kept = [
123
+ messages[0],
124
+ {"role": "user", "content": HANDOFF.format(summary=summary)},
125
+ *messages[cut:],
126
+ ]
127
+
128
+ # Shrink the retained tail now, while we are already paying for a rebuilt
129
+ # prefix. Stripping is idempotent, so from here the frozen block is final
130
+ # and stays byte-identical - and cached - until the next compaction.
131
+ strip(kept)
132
+ return kept
@@ -0,0 +1,21 @@
1
+ """Settings: real environment variables first, then ~/.agents/env."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ ENV_FILE = Path.home() / ".agents" / "env"
7
+
8
+ if ENV_FILE.exists():
9
+ for line in ENV_FILE.read_text().splitlines():
10
+ if "=" in line and not line.startswith("#"):
11
+ key, value = line.split("=", 1)
12
+ os.environ.setdefault(key.strip(), value.strip())
13
+
14
+ BASE_URL = os.environ["BASE_URL"]
15
+ API_KEY = os.environ["API_KEY"]
16
+ MODEL = os.environ.get("MODEL", "deepseek/deepseek-v4-flash")
17
+
18
+ # How much room the model has, and how we spend it.
19
+ CONTEXT_WINDOW = int(os.environ.get("CONTEXT_WINDOW", 128_000))
20
+ COMPACT_AT = 0.85 # compact once the prompt crosses this much of the window
21
+ COMPACT_TO = 0.35 # and cut back to this much, so it does not retrigger soon
@@ -0,0 +1,77 @@
1
+ """Late injection: a small block appended just before we send.
2
+
3
+ It goes at the END of the message list so the stable prefix in front of it
4
+ stays cached.
5
+ """
6
+
7
+ import hashlib
8
+ import subprocess
9
+ from datetime import datetime
10
+
11
+ from .todos import todos_prompt
12
+ from pathlib import Path
13
+
14
+ LABELS = {"M": "modified", "D": "deleted", "A": "added", "??": "new"}
15
+
16
+
17
+ def git(command):
18
+ result = subprocess.run(
19
+ f"git {command}", shell=True, capture_output=True, text=True
20
+ )
21
+ return result.stdout
22
+
23
+
24
+ def file_hash(path):
25
+ file = Path(path)
26
+ return hashlib.md5(file.read_bytes()).hexdigest() if file.is_file() else None
27
+
28
+
29
+ def git_state():
30
+ """path -> (status, content hash) for every file git sees as changed."""
31
+ state = {}
32
+ for line in git("status --porcelain").splitlines():
33
+ path = line[3:]
34
+ state[path] = (line[:2].strip(), file_hash(path))
35
+ return state
36
+
37
+
38
+ LAST_STATE = git_state()
39
+
40
+
41
+ def file_changes():
42
+ """Files whose status or contents moved since the previous turn."""
43
+ global LAST_STATE
44
+ now = git_state()
45
+ changed = {p: v[0] for p, v in now.items() if LAST_STATE.get(p) != v}
46
+ LAST_STATE = now
47
+ return changed
48
+
49
+
50
+ def changes_note():
51
+ changed = file_changes()
52
+ if not changed:
53
+ return ""
54
+ lines = [f"{LABELS.get(code, code)}: {path}" for path, code in changed.items()]
55
+ return (
56
+ "\n<system-reminder>\n"
57
+ "These files changed since your last turn. Read them again before "
58
+ "editing:\n" + "\n".join(lines) + "\n</system-reminder>"
59
+ )
60
+
61
+
62
+ def todos_note():
63
+ plan = todos_prompt()
64
+ return f"\n<todos>\n{plan}\n</todos>" if plan else ""
65
+
66
+
67
+ def reminder():
68
+ """The block we append to the messages on every turn."""
69
+ return {
70
+ "role": "user",
71
+ "content": (
72
+ "<env>\n"
73
+ f"time: {datetime.now():%Y-%m-%d %H:%M}\n"
74
+ f"git branch: {git('branch --show-current').strip() or '(detached)'}\n"
75
+ "</env>" + todos_note() + changes_note()
76
+ ),
77
+ }
@@ -0,0 +1,127 @@
1
+ """Keeping the transcript small enough to send.
2
+
3
+ Three mechanisms, cheapest first. Only the first two live here; the expensive
4
+ one is compact.py.
5
+
6
+ 1. cap - a fresh tool result is trimmed and the full text parked in a temp
7
+ file the agent can page through. Free: the decision is made once,
8
+ when the result is created, so it never edits the prefix.
9
+ 2. strip - once a turn is over, its tool results shrink to a stub. The edit
10
+ lands at the tail, right before the next user message, so the
11
+ cached prefix in front of it survives.
12
+ 3. drop - a single request is still too big. Throw tool results away whole,
13
+ oldest first, until it fits.
14
+
15
+ Everything here refuses to touch the locked prefix - the frozen
16
+ system + summary + head that compaction leaves behind. That block has to stay
17
+ byte-identical to stay cached.
18
+ """
19
+
20
+ import json
21
+ import tempfile
22
+ from pathlib import Path
23
+
24
+ from . import config
25
+
26
+ CAP = 10_000 # chars of a fresh tool result the agent sees inline
27
+ STUB = 300 # chars kept once the turn that produced it is over
28
+
29
+ TRIMMED = "[output trimmed:" # marker, so stripping twice is a no-op
30
+ SUMMARY = "<summary>" # marks the handoff note compaction leaves behind
31
+ SPILLS = [] # temp files belonging to the current turn
32
+
33
+
34
+ # ------------------------------------------------------------------- 1. cap
35
+
36
+
37
+ def spill(text):
38
+ """Park the full output on disk for the rest of this turn."""
39
+ handle = tempfile.NamedTemporaryFile(
40
+ mode="w", prefix="nightfall-", suffix=".txt", delete=False
41
+ )
42
+ handle.write(text)
43
+ handle.close()
44
+ SPILLS.append(Path(handle.name))
45
+ return handle.name
46
+
47
+
48
+ def cap(text):
49
+ """Trim a fresh tool result, leaving a pointer to the whole thing."""
50
+ if len(text) <= CAP:
51
+ return text
52
+
53
+ try:
54
+ path = spill(text)
55
+ except OSError:
56
+ # No temp file (read-only /tmp, no space). Still better to trim and
57
+ # say so than to fail the tool call outright.
58
+ return text[:CAP] + f"\n\n{TRIMMED} {len(text) - CAP} chars cut and the rest could not be saved.]"
59
+ return (
60
+ text[:CAP] + f"\n\n{TRIMMED} {len(text) - CAP} of {len(text)} chars cut. "
61
+ f"The whole output is at {path} - page through it with "
62
+ "head, tail, sed -n or grep. It is deleted when this turn ends.]"
63
+ )
64
+
65
+
66
+ def sweep():
67
+ """Delete this turn's temp files. Their paths die with the tool results."""
68
+ for path in SPILLS:
69
+ path.unlink(missing_ok=True)
70
+ SPILLS.clear()
71
+
72
+
73
+ def locked(messages):
74
+ """Length of the frozen prefix - everything up to and including the newest
75
+ summary. Derived rather than remembered, so it stays correct across
76
+ /compact, /rewind and switching sessions."""
77
+ for index in range(len(messages) - 1, -1, -1):
78
+ if SUMMARY in (messages[index].get("content") or ""):
79
+ return index + 1
80
+ return 0
81
+
82
+
83
+ # ----------------------------------------------------------------- 2. strip
84
+
85
+
86
+ def strip(messages):
87
+ """Shrink every tool result that is no longer part of the live turn.
88
+
89
+ Called once a turn has finished, so by now "everything unlocked" and
90
+ "everything the model no longer needs in full" are the same set.
91
+ """
92
+ shrunk = 0
93
+ for message in messages[locked(messages):]:
94
+ content = message.get("content") or ""
95
+ if message["role"] != "tool" or TRIMMED in content or len(content) <= STUB:
96
+ continue
97
+
98
+ message["content"] = (
99
+ content[:STUB] + f"\n\n{TRIMMED} {len(content) - STUB} more chars. "
100
+ "Run the command again if you need them.]"
101
+ )
102
+ shrunk += 1
103
+ return shrunk
104
+
105
+
106
+ # ------------------------------------------------------------------ 3. drop
107
+
108
+
109
+ def estimate(messages):
110
+ """Rough token count. Good enough to decide whether to panic."""
111
+ return sum(len(json.dumps(m)) for m in messages) // 4
112
+
113
+
114
+ def fit(messages):
115
+ """Last resort: discard whole tool results, oldest first, until it fits.
116
+
117
+ Returns how many went. Normally zero - cap and strip do the real work.
118
+ """
119
+ budget = config.CONTEXT_WINDOW * config.COMPACT_AT
120
+ dropped = 0
121
+ for message in messages[locked(messages):]:
122
+ if estimate(messages) <= budget:
123
+ break
124
+ if message["role"] == "tool" and TRIMMED not in (message.get("content") or ""):
125
+ message["content"] = f"{TRIMMED} dropped to fit the context window.]"
126
+ dropped += 1
127
+ return dropped