Skip to content
Merged
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
15 changes: 10 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,14 @@ Status of the `main` branch. Changes prior to the next official version change w
* General:
- Support environment variable `SERENA_USAGE_REPORTING` (set to `false` to disable usage reporting)
- Extended the list of always ignored directories (by language servers) with common cases.
- Improve exposed toolset: With mode switching no longer being a feature, we now fully apply tool exclusions
defined by modes when in a single-project context (limiting exposed tools to a minimum)
- Fix: When scanning for `.gitignore` files, the presence of files that could not be made relative
to the project root would cause the scan to fail. #1317

Dashboard:
- Fix handling of read news, saving each read news entry separately #1338

JetBrains:
- Improve handling of `relative_path` parameter
- Improve its documentation to avoid usage errors
Expand All @@ -18,7 +23,7 @@ JetBrains:
- Add mSL (mIRC Scripting Language) support (custom pygls-based language server; symbols, references, definitions)
- Fix initialisation issues in Vue language server #1333

# 1.1.1
# v1.1.1 (2026-04-12)

* General:
- Enable cert verification for HTTPS request to oraios-software.de #1320
Expand All @@ -30,7 +35,7 @@ JetBrains:
- Fix Dart LSP returning only symbol name as body instead of full method body.


# 1.1.0
# v1.1.0 (2026-04-11)

* General:
- **Major**: Add commands for hooks and documentation of recommended setup. Consider setting up the [recommended hooks](https://oraios.github.io/serena/02-usage/030_clients.html) !
Expand All @@ -53,7 +58,7 @@ JetBrains:
Some clients would terminate the MCP server in a way that did not ensure proper termination.
- Fix: Manual server shutdown triggered by GUI tool/dashboard not cleaning everything up.

# 1.0.0
# v1.0.0 (2026-04-03)

* General:
* Add monorepo/multi-language support
Expand Down Expand Up @@ -134,7 +139,7 @@ JetBrains:
* **C/C++ alternate LS (ccls)**: Add experimental, opt-in support for ccls as an alternative backend to clangd. Enable via `cpp_ccls` in project configuration. Requires `ccls` installed and ideally a `compile_commands.json` at repo root.
* **Add support for Solidity** via the Nomic Foundation `@nomicfoundation/solidity-language-server` (automatically installed via npm)

# 0.1.4
# v0.1.4 (2025-08-15)

## Summary

Expand Down Expand Up @@ -178,7 +183,7 @@ Fixes:
default shell reconfiguration imposed by Claude Code)
* Additional wait for initialization in C# language server before requesting references, allowing cross-file references to be found.

# 0.1.3
# v0.1.3 (2025-07-22)

## Summary

Expand Down
15 changes: 15 additions & 0 deletions news/20260414.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<div class="news-item">
<h3>Agents Evaluating Serena for Themselves & Maintenance Update</h3>
<p class="date">April 14, 2026</p>
<p>
<b>Evaluation.</b>
We had our actual "end users", i.e. coding agents, evaluate the added value of Serena's tools based on their own empirical insights.
Read what the agents had to say in our <a target="_blank" href="https://oraios.github.io/serena/04-evaluation/000_evaluation-intro.html">evaluation report</a>.<br>
This represents the first systematic evaluation we ever conducted.
</p>
<p>
<b>Maintenance Update.</b> The new v1.1.2 release adds several improvements and fixes.<br>
If you have already switched to the uv tool installation of Serena, upgrade as follows:<br>
uv tool upgrade serena-agent --prerelease=allow
</p>
</div>
5 changes: 4 additions & 1 deletion scripts/bump_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import logging
import os
import re
from datetime import datetime
from pathlib import Path
from typing import Literal

Expand Down Expand Up @@ -185,7 +186,9 @@ def update_changelog(changelog_text: str, new_version: str) -> str:
unreleased_body = unreleased_section[len(_UNRELEASED_HEADER) :]
intro, unreleased_entries = split_unreleased_body(unreleased_body)

updated_section = _UNRELEASED_HEADER + intro + f"# {new_version}\n"
date_str = datetime.now().strftime("%Y-%m-%d")
updated_section = _UNRELEASED_HEADER + intro + f"# v{new_version} ({date_str})\n"

if unreleased_entries.strip():
updated_section += "\n" + unreleased_entries.lstrip("\n")
else:
Expand Down
36 changes: 20 additions & 16 deletions src/serena/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -453,31 +453,35 @@ def _create_base_toolset(
tool_inclusion_definitions.append(serena_config)
tool_inclusion_definitions.append(context)

# determine whether we are operating in a single-project context
# (i.e. the project that is activated at startup is the only project that will be worked with throughout the session)
is_single_project = context.single_project and project is not None

# consider modes
# Since modes can be dynamically turned on and off, we don't include their definitions directly,
# For the initially active dynamic modes, we make sure that the tools they enable are included.
for mode in modes.get_default_modes():
tool_inclusion_definitions.append(
NamedToolInclusionDefinition(
name=f"InitialDynamicModeInclusions[{mode.name}]", included_optional_tools=mode.included_optional_tools
)
)
# For the base modes, we also apply the tool exclusions, since they apply throughout the entire session
# * base modes: These cannot be changed, so they are fully applied
for base_mode in modes.get_base_modes():
tool_inclusion_definitions.append(
NamedToolInclusionDefinition(
name=f"BaseMode[{base_mode.name}]",
included_optional_tools=base_mode.included_optional_tools,
excluded_tools=base_mode.excluded_tools,
tool_inclusion_definitions.append(base_mode)
# * default modes: When not in a single-project context, these modes are dynamic (can later be turned off),
# so we consider only their inclusions (but not their exclusions, because these must not be hard)
for mode in modes.get_default_modes():
if is_single_project:
tool_inclusion_definitions.append(mode)
else:
# Since modes can be dynamically turned on and off, we don't include their definitions directly,
# For the initially active dynamic modes, we make sure that the tools they enable are included.
tool_inclusion_definitions.append(
NamedToolInclusionDefinition(
name=f"InitialDynamicModeInclusions[{mode.name}]", included_optional_tools=mode.included_optional_tools
)
)
)

# When in a single-project context, the agent is assumed to work on a single project, and we thus
# want to apply that project's tool exclusions/inclusions from the get-go, limiting the set
# of tools that will be exposed to the client.
# Furthermore, we disable tools that are only relevant for project activation.
# So if the project exists, we apply all the aforementioned exclusions.
if context.single_project and project is not None:
if is_single_project:
assert project is not None
log.info(
"Applying tool inclusion/exclusion definitions for single-project context based on project '%s'",
project.project_name,
Expand Down
8 changes: 7 additions & 1 deletion src/serena/config/context_mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,13 @@ def print_overview(self) -> None:
"""Print an overview of the mode."""
print(f"{self.name}:\n {self.description}")
if self.excluded_tools:
print(" excluded tools:\n " + ", ".join(sorted(self.excluded_tools)))
print(" excluded tools:\n " + ", ".join(sorted(self.excluded_tools)))
if self.included_optional_tools:
print(" included optional tools:\n " + ", ".join(sorted(self.included_optional_tools)))
if self.fixed_tools:
print(" fixed tools:\n " + ", ".join(sorted(self.fixed_tools)))
if self.prompt:
print(" defines initial prompt")

@classmethod
def from_yaml(cls, yaml_path: str | Path) -> Self:
Expand Down
6 changes: 5 additions & 1 deletion src/serena/config/serena_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,11 @@ def __init__(self) -> None:
If a name of a mode matches a name of a mode in SERENAS_OWN_MODES_YAML_DIR,
the user mode will override the default mode definition.
"""
self.news_snippet_id_file: str = os.path.join(self.serena_user_home_dir, "last_read_news_snippet_id.txt")
self.news_legacy_last_read_id_file: str = os.path.join(self.serena_user_home_dir, "last_read_news_snippet_id.txt")
"""
file containing the ID of the last read news snippet
"""
self.news_read_items_file: str = os.path.join(self.serena_user_home_dir, "news_read.pkl")
"""
file containing the ID of the last read news snippet
"""
Expand Down
68 changes: 55 additions & 13 deletions src/serena/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from PIL import Image
from pydantic import BaseModel
from sensai.util import logging
from sensai.util.pickle import dump_pickle, load_pickle

from serena.analytics import ToolUsageStats
from serena.config.serena_config import SerenaConfig, SerenaPaths
Expand Down Expand Up @@ -132,6 +133,54 @@ def from_task_info(cls, task_info: TaskExecutor.TaskInfo) -> Self:
)


class ReadNews:
def __init__(self, read_ids: list[str], legacy_last_read_id: str | None = None):
self._read_ids = set(read_ids)
self._legacy_last_read_id = legacy_last_read_id

@staticmethod
def load() -> "ReadNews":
read_news_path = SerenaPaths().news_read_items_file
legacy_last_read_id_path = SerenaPaths().news_legacy_last_read_id_file

def load_legacy_last_read_id() -> str | None:
if not os.path.exists(legacy_last_read_id_path):
return None
with open(legacy_last_read_id_path, encoding="utf-8") as f:
last_read_news_id = f.read().strip()
if last_read_news_id == "20262103":
last_read_news_id = "20260321" # fix originally misnamed news id
return last_read_news_id

if os.path.exists(read_news_path):
return load_pickle(read_news_path)
else:
instance = ReadNews(read_ids=[], legacy_last_read_id=load_legacy_last_read_id())
instance._save()
try:
os.unlink(legacy_last_read_id_path)
except:
pass
return instance

def _save(self) -> None:
dump_pickle(self, SerenaPaths().news_read_items_file)

def is_read(self, identifier: str) -> bool:
if identifier in self._read_ids:
return True
if self._legacy_last_read_id is not None and identifier <= self._legacy_last_read_id:
return True
return False

def mark_read(self, identifier: str) -> None:
"""
Marks the given news snippet as read, saving the new state to disk
"""
self._read_ids.add(identifier)
self._save()


class SerenaDashboardAPI:
log = logging.getLogger(__qualname__)

Expand All @@ -150,6 +199,7 @@ def __init__(
self._loaded_news: dict[str, str] = {}
self._news_ready = threading.Event()
self._setup_routes()
self._read_news = ReadNews.load()
# Fetch remote news in background on startup (non-blocking)
threading.Thread(target=self._fetch_news, daemon=True).start()

Expand Down Expand Up @@ -362,25 +412,19 @@ def _fetch_unread_news() -> dict[str, str]:
self._news_ready.wait()
all_news = self._loaded_news

# Filter news items by installation date
serena_config_creation_date = SerenaConfig.get_config_file_creation_date()
if serena_config_creation_date is None:
# should not normally happen, since config file should exist when the dashboard is started
# We assume a fresh installation in this case
log.error("Serena config file not found when starting the dashboard")
return {}
serena_config_creation_date = serena_config_creation_date.strftime("%Y%m%d")
# Only include news items published on or after the installation date

# filter for news after the installation date
post_installation_news = {k: v for k, v in all_news.items() if k >= serena_config_creation_date}

news_snippet_id_file = SerenaPaths().news_snippet_id_file
if not os.path.exists(news_snippet_id_file):
return post_installation_news
with open(news_snippet_id_file, encoding="utf-8") as f:
last_read_news_id = f.read().strip()
if last_read_news_id == "20262103":
last_read_news_id = "20260321" # fix originally misnamed news id
return {k: v for k, v in post_installation_news.items() if k > last_read_news_id}
# read unread news
return {k: v for k, v in post_installation_news.items() if not self._read_news.is_read(k)}

try:
unread_news = _fetch_unread_news()
Expand All @@ -393,9 +437,7 @@ def mark_news_snippet_as_read() -> dict[str, str]:
try:
request_data = request.get_json()
news_snippet_id = str(request_data.get("news_snippet_id"))
news_snippet_id_file = SerenaPaths().news_snippet_id_file
with open(news_snippet_id_file, "w", encoding="utf-8") as f:
f.write(news_snippet_id)
self._read_news.mark_read(news_snippet_id)
return {"status": "success", "message": f"Marked news snippet {news_snippet_id} as read"}
except Exception as e:
return {"status": "error", "message": str(e)}
Expand Down
Loading