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 ADDED
File without changes
nightfall/agent.py ADDED
@@ -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()
nightfall/commands.py ADDED
@@ -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
nightfall/compact.py ADDED
@@ -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
nightfall/config.py ADDED
@@ -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
nightfall/context.py ADDED
@@ -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
+ }
nightfall/history.py ADDED
@@ -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
nightfall/llm.py ADDED
@@ -0,0 +1,90 @@
1
+ import json
2
+ import os
3
+
4
+ from openai import OpenAI
5
+
6
+ from . import config
7
+ from .skills import skills_prompt
8
+ from .tools import TOOLS, TOOL_SCHEMAS
9
+
10
+ client = OpenAI(
11
+ base_url=config.BASE_URL,
12
+ api_key=config.API_KEY,
13
+ )
14
+
15
+ SYSTEM_PROMPT = f"""
16
+ You are a coding agent. Your job is to code. Always code.
17
+ Use the bash tool to inspect files.
18
+ Use write_file to create files and str_replace to edit them.
19
+ Answer back to the user once exploration is done.
20
+
21
+ For any task that takes more than one step, call write_todos first and plan it
22
+ out. Send the whole list every time you call it - it replaces the old one.
23
+ Keep exactly one task in_progress, mark it done the moment it is finished, and
24
+ move the next one to in_progress in the same call. Do not batch up completions
25
+ at the end. Skip the tool entirely for single-step tasks; it is noise there.
26
+
27
+ The current list is injected back to you every turn inside <todos> tags, so
28
+ that block - not the transcript - is the truth about where you are.
29
+
30
+ When you need to understand how something works - where a feature lives, how
31
+ data flows, what calls what - send a task subagent instead of grepping your
32
+ way there yourself. It explores in its own context window and hands you back
33
+ just the findings, so the search does not fill yours. It cannot see this
34
+ conversation, so write the question so it stands alone. Do all editing
35
+ yourself; the subagent only reads.
36
+
37
+ Long tool output is cut short, and the whole thing is written to a temp file
38
+ whose path is given at the cut. Page through it with head, tail, sed -n or
39
+ grep rather than asking for it again. That file only exists for the current
40
+ turn, so read it now or re-run the command later.
41
+
42
+ Your current working directory is: {os.getcwd()}
43
+
44
+ You have skills available. Each one is a set of instructions for a task.
45
+ If a skill matches what the user wants, call read_skill first and follow it.
46
+
47
+ {skills_prompt()}
48
+ """
49
+
50
+
51
+ def call_llm(messages, tools=None):
52
+ response = client.chat.completions.create(
53
+ model=config.MODEL,
54
+ messages=messages,
55
+ tools=tools or TOOL_SCHEMAS,
56
+ )
57
+
58
+ message = response.choices[0].message
59
+
60
+ completion_details = response.usage.completion_tokens_details
61
+ prompt_details = response.usage.prompt_tokens_details
62
+
63
+ usage = {
64
+ "prompt_tokens": response.usage.prompt_tokens,
65
+ "completion_tokens": response.usage.completion_tokens,
66
+ "reasoning_tokens": getattr(completion_details, "reasoning_tokens", None),
67
+ "cached_tokens": getattr(prompt_details, "cached_tokens", None),
68
+ }
69
+
70
+ return message, usage
71
+
72
+
73
+ if __name__ == "__main__":
74
+ user_input = input("Enter your prompt> ")
75
+
76
+ message, usage = call_llm([
77
+ {"role": "system", "content": SYSTEM_PROMPT},
78
+ {"role": "user", "content": user_input},
79
+ ])
80
+
81
+ print("\nAgent: ", message.content, "\n")
82
+
83
+ if message.tool_calls:
84
+ tool_call = message.tool_calls[0]
85
+ args = json.loads(tool_call.function.arguments)
86
+ result = TOOLS[tool_call.function.name](**args)
87
+ print("Tool: ", tool_call.function.name, args)
88
+ print(result, "\n")
89
+
90
+ print(usage)