Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions bright_vision_core/headless_args.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,9 @@ def default_headless_args(*, yes: bool = False) -> SimpleNamespace:
cost_limit=float("inf"),
disable_scraping=True,
use_enhanced_map=False,
auto_save=False,
auto_load=False,
auto_save_session_name="auto-save",
session_encrypt=False,
session_key_file=None,
)
37 changes: 37 additions & 0 deletions bright_vision_core/headless_persistence.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""Map Vision / BrightVision session persistence options onto cecli headless args."""

from __future__ import annotations

import os
from types import SimpleNamespace


def apply_persistence_to_args(
args: SimpleNamespace,
*,
session_encrypt: bool = False,
session_key_file: str | None = None,
auto_save: bool = False,
auto_load: bool = False,
auto_save_session_name: str = "auto-save",
) -> SimpleNamespace:
"""Return a copy of *args* with persistence fields set (does not mutate *args*)."""
out = SimpleNamespace(**vars(args))
out.session_encrypt = bool(session_encrypt)
out.session_key_file = session_key_file
out.auto_save = bool(auto_save)
out.auto_load = bool(auto_load)
out.auto_save_session_name = auto_save_session_name or "auto-save"
if out.session_encrypt and not session_crypto_key_available(session_key_file):
out.session_encrypt = False
return out


def session_crypto_key_available(session_key_file: str | None = None) -> bool:
from cecli import session_crypto

return session_crypto.resolve_key(key_file=session_key_file) is not None


def persistence_env_active() -> bool:
return bool(os.environ.get("CECLI_SESSION_KEY", "").strip())
40 changes: 39 additions & 1 deletion bright_vision_core/http_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,30 @@ class CreateSessionRequest(BaseModel):
auto_commits: bool = True
dirty_commits: bool = True
dry_run: bool = False
session_encrypt: bool = Field(
False,
description="Encrypt saved sessions (requires CECLI_SESSION_KEY from OS keychain on desktop)",
)
session_key_file: str | None = Field(
default=None,
description="Optional path to a session encryption key file",
)
auto_save: bool = Field(
False,
description="Auto-save session JSON to .cecli/sessions/ (cecli --auto-save)",
)
auto_load: bool = Field(
False,
description="Load auto-save session on session create (cecli --auto-load)",
)
auto_save_session_name: str = Field(
"brightvision",
description="Basename for auto-save/load under .cecli/sessions/",
)
chat_history_file: bool = Field(
True,
description="Append turns to .cecli/chat.history (cecli chat history file)",
)


class ConfirmRequest(BaseModel):
Expand Down Expand Up @@ -357,6 +381,12 @@ def create_session(body: CreateSessionRequest):
dirty_commits=body.dirty_commits,
dry_run=body.dry_run,
model_router=router_payload,
session_encrypt=body.session_encrypt,
session_key_file=body.session_key_file,
auto_save=body.auto_save,
auto_load=body.auto_load,
auto_save_session_name=body.auto_save_session_name,
chat_history_file=body.chat_history_file,
)
except FileNotFoundError as err:
raise HTTPException(status_code=404, detail=str(err)) from err
Expand Down Expand Up @@ -667,7 +697,7 @@ def _wait_spec_job(job_id: str) -> GenerateTodoSpecResponse:

@app.post("/workspaces/todos/{todo_id}/sync-spec-files", response_model=TodoItemModel)
def sync_workspace_spec_files(workspace: str, todo_id: str):
"""Import three-layer markdown from ``.aider-vision/specs/{id}/`` into todos.json."""
"""Import three-layer markdown from ``.brightvision/specs/{id}/`` into todos.json."""
api = _todos_for_workspace(workspace)
try:
item = api.import_spec_files(todo_id)
Expand Down Expand Up @@ -800,6 +830,14 @@ def upload_session_files(session_id: str, body: UploadFilesRequest):
)


@app.post("/sessions/{session_id}/interrupt")
def interrupt_session_turn(session_id: str):
"""Stop an in-flight turn (UI Stop); complements SSE disconnect."""
session = _get_session(session_id)
session.interrupt_turn()
return {"ok": True}


@app.post("/sessions/{session_id}/messages")
def post_message(session_id: str, body: MessageRequest):
session = _get_session(session_id)
Expand Down
175 changes: 152 additions & 23 deletions bright_vision_core/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

import base64
import os
import shlex
import threading
import time
from collections.abc import Callable
Expand All @@ -16,11 +15,14 @@
_T = TypeVar("_T")

# Wall-clock cap for slash/preproc (e.g. `/agent` on a local model looping on tools).
SLASH_PREPROC_TIMEOUT_S = float(os.environ.get("VISION_SLASH_PREPROC_TIMEOUT_S", "600"))
SLASH_PREPROC_TIMEOUT_S = float(os.environ.get("VISION_SLASH_PREPROC_TIMEOUT_S", "300"))

from cecli import models
from cecli.coders import Coder
from cecli.commands import Commands
from cecli.commands import Commands, SwitchCoderSignal
from cecli.commands.add import AddCommand
from cecli.commands.utils.helpers import quote_filename
from cecli.utils import is_image_file

from bright_vision_core.async_bridge import (
HEARTBEAT_PULSE,
Expand All @@ -33,8 +35,10 @@
from bright_vision_core.git_undo import undo_last_aider_commit_for_coder
from bright_vision_core.git_workspace import create_git_workspace
from bright_vision_core.headless_args import default_headless_args
from bright_vision_core.headless_persistence import apply_persistence_to_args
from bright_vision_core.todo_spec_generate import build_generate_message, parse_generated_layers
from bright_vision_core.slash_helpers import is_switch_coder_signal, run_slash_command_sync
from bright_vision_core.workspace_paths import attachments_dir, attachments_prefix
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Broken workspace_paths import 🐞 Bug ≡ Correctness

bright_vision_core.session imports bright_vision_core.workspace_paths, but that module is not
present in the package, so importing Session (and starting http_api) will crash with
ModuleNotFoundError. stage_uploaded_file() also calls attachments_dir(), so uploads break even
if the import were patched ad-hoc.
Agent Prompt
### Issue description
`bright_vision_core/session.py` imports `attachments_dir`/`attachments_prefix` from `bright_vision_core.workspace_paths`, but the module is missing from the repo, causing runtime import failure.

### Issue Context
This import is executed when the FastAPI app imports `Session`, so it prevents the Vision API from starting.

### Fix Focus Areas
- Add module: `bright_vision_core/workspace_paths.py` with `attachments_dir(workspace: Path) -> Path` and `attachments_prefix() -> str` (and any other helpers you intended).
- Or remove the import and inline the path logic in `bright_vision_core/session.py`.

### Fix Focus Areas (paths/lines)
- bright_vision_core/session.py[37-42]
- bright_vision_core/session.py[676-679]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

from bright_vision_core.model_router import (
ModelRouterConfig,
RouteDecision,
Expand Down Expand Up @@ -125,6 +129,7 @@ def worker() -> None:
if timeout_s is not None and time.monotonic() - started > timeout_s:
if on_timeout:
on_timeout()
done.wait(timeout=3.0)
raise TimeoutError(f"{message} timed out after {int(timeout_s)}s")
pulse += 1
emit_progress(io, label=label, message=f"{message} ({int(pulse * wait_s)}s)")
Expand Down Expand Up @@ -190,6 +195,12 @@ def create(
on_event=None,
echo_to_console: bool = False,
model_router: ModelRouterConfig | dict[str, Any] | None = None,
session_encrypt: bool = False,
session_key_file: str | None = None,
auto_save: bool = False,
auto_load: bool = False,
auto_save_session_name: str = "brightvision",
chat_history_file: bool | str | None = True,
) -> Session:
workspace = Path(workspace_dir).resolve()
if not workspace.is_dir():
Expand All @@ -203,7 +214,22 @@ def create(
prev_cwd = os.getcwd()
os.chdir(workspace)
try:
io = EventIO(yes=yes, pretty=False, on_event=on_event, echo_to_console=echo_to_console)
cecli_meta = workspace / ".cecli"
if chat_history_file is True:
chat_hist_path = str(cecli_meta / "chat.history")
elif chat_history_file:
chat_hist_path = str(Path(chat_history_file).expanduser())
else:
chat_hist_path = None
io_kwargs: dict[str, Any] = {
"yes": yes,
"pretty": False,
"on_event": on_event,
"echo_to_console": echo_to_console,
}
if chat_hist_path:
io_kwargs["chat_history_file"] = chat_hist_path
io = EventIO(**io_kwargs)
model_name = model or models.DEFAULT_MODEL_NAME
router_cfg = (
ModelRouterConfig.from_payload(model_router)
Expand Down Expand Up @@ -237,6 +263,14 @@ def create(
map_tokens = main_model.get_repo_map_tokens()

commands = Commands(io, None)
headless_args = apply_persistence_to_args(
default_headless_args(yes=yes),
session_encrypt=session_encrypt,
session_key_file=session_key_file,
auto_save=auto_save,
auto_load=auto_load,
auto_save_session_name=auto_save_session_name,
)
coder = run(
Coder.create(
main_model=main_model,
Expand All @@ -250,11 +284,20 @@ def create(
map_tokens=map_tokens,
commands=commands,
use_git=repo is not None,
args=default_headless_args(yes=yes),
args=headless_args,
)
)
commands.coder = coder
rebind_coder_loop_primitives(coder)
if headless_args.auto_load:
from cecli.sessions import SessionManager

manager = SessionManager(coder, io)
name = headless_args.auto_save_session_name or "auto-save"
try:
run(manager.load_session(name, switch=False))
except Exception:
pass
Comment on lines +299 to +300
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: Broad exception with 'pass' silently ignores all session loading errors. Emit a warning event to inform the user of failures.

Suggested change
except Exception:
pass
except Exception as e:
io.emit("warning", text=f"Could not auto-load session '{name}': {e}")

return cls(coder, io, model_router=router_cfg if router_cfg and router_cfg.enabled else None)
finally:
os.chdir(prev_cwd)
Expand Down Expand Up @@ -391,8 +434,9 @@ async def _preproc_coro():
yield self.io.emit(
"error",
text=(
f"{err}. Stop the turn or retry with a simpler prompt. "
"Local agent mode may loop on tools (e.g. repeated ls)."
f"{err}. Use Stop, then retry without /agent for quick edits. "
"Local agent mode may loop on tools (e.g. repeated ls). "
f"Cap: VISION_SLASH_PREPROC_TIMEOUT_S (default {int(SLASH_PREPROC_TIMEOUT_S)}s)."
),
)
yield self.io.emit(
Expand Down Expand Up @@ -518,35 +562,120 @@ async def _preproc_coro():
return
raise

def _resolve_workspace_file(self, raw: str) -> str | None:
"""Return workspace-relative posix path for an on-disk file, or None after tool_error."""
workspace = Path(self.coder.root).resolve()
p = Path(raw.strip().lstrip("@"))
if not p.is_absolute():
p = workspace / p
p = p.resolve()
if not p.is_file():
self.io.tool_error(f"Not a file: {p}")
return None
try:
return p.relative_to(workspace).as_posix()
except ValueError:
self.io.tool_error(f"File outside workspace: {p}")
return None

def _add_matched_file_to_chat(self, rel: str) -> bool:
"""Add one file like cecli ``/add`` without create-file confirms."""
coder = self.coder
io = self.io
abs_file_path = coder.abs_root_path(rel)

blocked = AddCommand._add_blocked_message(coder, rel)
if blocked:
io.tool_error(blocked)
return False

if abs_file_path in coder.abs_fnames:
io.tool_output(f"{rel} is already in the chat")
return True
if abs_file_path in coder.abs_read_only_stubs_fnames:
if coder.repo and coder.repo.path_in_repo(rel):
coder.abs_read_only_stubs_fnames.remove(abs_file_path)
coder.abs_fnames.add(abs_file_path)
io.tool_output(f"Moved {rel} from read-only (stub) to editable files in the chat")
else:
io.tool_error(f"Cannot add {rel} as it's not part of the repository")
return False
elif abs_file_path in coder.abs_read_only_fnames:
if coder.repo and coder.repo.path_in_repo(rel):
coder.abs_read_only_fnames.remove(abs_file_path)
coder.abs_fnames.add(abs_file_path)
io.tool_output(f"Moved {rel} from read-only to editable files in the chat")
else:
io.tool_error(f"Cannot add {rel} as it's not part of the repository")
return False
else:
if is_image_file(rel) and not coder.main_model.info.get("supports_vision"):
io.tool_error(
f"Cannot add image file {rel} as the {coder.main_model.name} "
"does not support images."
)
return False
content = io.read_text(abs_file_path)
if content is None:
io.tool_error(f"Unable to read {rel}")
return False
coder.abs_fnames.add(abs_file_path)
io.tool_output(f"Added {rel} to the chat")
coder.check_added_files()
if hasattr(coder, "use_enhanced_context") and coder.use_enhanced_context:
if hasattr(coder, "_calculate_context_block_tokens"):
coder._calculate_context_block_tokens()
return True

def _finish_file_adds_like_slash_add(self) -> None:
"""Match cecli ``/add`` post-success coder refresh (SwitchCoderSignal)."""
coder = self.coder
if coder.repo_map:
map_tokens = coder.repo_map.max_map_tokens
map_mul_no_files = coder.repo_map.map_mul_no_files
else:
map_tokens = 0
map_mul_no_files = 1
raise SwitchCoderSignal(
edit_format=coder.edit_format,
summarize_from_coder=False,
from_coder=coder,
map_tokens=map_tokens,
map_mul_no_files=map_mul_no_files,
show_announcements=False,
)

def add_files(self, paths: list[str]) -> list[dict[str, Any]]:
if not paths:
return []

workspace = Path(self.coder.root).resolve()
attach_prefix = attachments_prefix()
quoted: list[str] = []
direct_added = False
for raw in paths:
raw = raw.strip().lstrip("@")
p = Path(raw)
if not p.is_absolute():
p = workspace / p
p = p.resolve()
if not p.is_file():
self.io.tool_error(f"Not a file: {p}")
rel = self._resolve_workspace_file(raw)
if rel is None:
continue
try:
rel = p.relative_to(workspace)
quoted.append(shlex.quote(str(rel).replace("\\", "/")))
except ValueError:
quoted.append(shlex.quote(str(p)))
if rel.startswith(attach_prefix):
if self._add_matched_file_to_chat(rel):
direct_added = True
continue
quoted.append(quote_filename(rel))

if quoted:
run_slash_command_sync(self.coder, "add", " ".join(quoted))
try:
if quoted:
run_slash_command_sync(self.coder, "add", " ".join(quoted))
elif direct_added:
self._finish_file_adds_like_slash_add()
except BaseException as exc:
if not is_switch_coder_signal(exc):
raise

return self.io.drain_events()

def stage_uploaded_file(self, filename: str, content: bytes) -> Path:
workspace = Path(self.coder.root).resolve()
attach_dir = workspace / ".aider-vision" / "attachments"
attach_dir = attachments_dir(workspace)
attach_dir.mkdir(parents=True, exist_ok=True)

safe_name = Path(filename).name or "upload"
Expand Down
2 changes: 1 addition & 1 deletion cecli
Submodule cecli updated 450 files
Loading
Loading