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.
- nightfall_cli-0.1.0/.github/workflows/workflow.yml +37 -0
- nightfall_cli-0.1.0/.gitignore +10 -0
- nightfall_cli-0.1.0/.python-version +1 -0
- nightfall_cli-0.1.0/PKG-INFO +58 -0
- nightfall_cli-0.1.0/README.md +47 -0
- nightfall_cli-0.1.0/nightfall/__init__.py +0 -0
- nightfall_cli-0.1.0/nightfall/agent.py +88 -0
- nightfall_cli-0.1.0/nightfall/commands.py +78 -0
- nightfall_cli-0.1.0/nightfall/compact.py +132 -0
- nightfall_cli-0.1.0/nightfall/config.py +21 -0
- nightfall_cli-0.1.0/nightfall/context.py +77 -0
- nightfall_cli-0.1.0/nightfall/history.py +127 -0
- nightfall_cli-0.1.0/nightfall/llm.py +90 -0
- nightfall_cli-0.1.0/nightfall/permissions.py +120 -0
- nightfall_cli-0.1.0/nightfall/prompt.py +61 -0
- nightfall_cli-0.1.0/nightfall/sandbox.py +210 -0
- nightfall_cli-0.1.0/nightfall/session.py +86 -0
- nightfall_cli-0.1.0/nightfall/skills.py +34 -0
- nightfall_cli-0.1.0/nightfall/subagent.py +166 -0
- nightfall_cli-0.1.0/nightfall/todos.py +66 -0
- nightfall_cli-0.1.0/nightfall/tools.py +198 -0
- nightfall_cli-0.1.0/nightfall/ui.py +290 -0
- nightfall_cli-0.1.0/pyproject.toml +22 -0
- nightfall_cli-0.1.0/tests/test_sandbox.py +42 -0
- nightfall_cli-0.1.0/uv.lock +532 -0
|
@@ -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 @@
|
|
|
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
|