diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4542aa56..ee9434b6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
@@ -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
@@ -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) !
@@ -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
@@ -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
@@ -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
diff --git a/news/20260414.html b/news/20260414.html
new file mode 100644
index 00000000..5e2a0d46
--- /dev/null
+++ b/news/20260414.html
@@ -0,0 +1,15 @@
+
+
Agents Evaluating Serena for Themselves & Maintenance Update
+
April 14, 2026
+
+ Evaluation.
+ 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 evaluation report.
+ This represents the first systematic evaluation we ever conducted.
+
+
+ Maintenance Update. The new v1.1.2 release adds several improvements and fixes.
+ If you have already switched to the uv tool installation of Serena, upgrade as follows:
+ uv tool upgrade serena-agent --prerelease=allow
+
+
\ No newline at end of file
diff --git a/scripts/bump_version.py b/scripts/bump_version.py
index e3426baa..7647da78 100644
--- a/scripts/bump_version.py
+++ b/scripts/bump_version.py
@@ -3,6 +3,7 @@ from __future__ import annotations
import logging
import os
import re
+from datetime import datetime
from pathlib import Path
from typing import Literal
@@ -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:
diff --git a/src/serena/agent.py b/src/serena/agent.py
index 322db7c8..30ef7cc5 100644
--- a/src/serena/agent.py
+++ b/src/serena/agent.py
@@ -453,31 +453,35 @@ class SerenaAgent:
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,
diff --git a/src/serena/config/context_mode.py b/src/serena/config/context_mode.py
index e417fe32..053f54ee 100644
--- a/src/serena/config/context_mode.py
+++ b/src/serena/config/context_mode.py
@@ -53,7 +53,13 @@ class SerenaAgentMode(ToolInclusionDefinition, ToStringMixin):
"""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:
diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py
index 987d8cf6..a9cefd3b 100644
--- a/src/serena/config/serena_config.py
+++ b/src/serena/config/serena_config.py
@@ -83,7 +83,11 @@ class SerenaPaths:
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
"""
diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py
index 3c33f1a2..8ed27a5f 100644
--- a/src/serena/dashboard.py
+++ b/src/serena/dashboard.py
@@ -15,6 +15,7 @@ from flask import Flask, Response, redirect, request, send_from_directory
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
@@ -132,6 +133,54 @@ class QueuedExecution(BaseModel):
)
+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__)
@@ -150,6 +199,7 @@ class SerenaDashboardAPI:
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()
@@ -362,7 +412,6 @@ class SerenaDashboardAPI:
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
@@ -370,17 +419,12 @@ class SerenaDashboardAPI:
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()
@@ -393,9 +437,7 @@ class SerenaDashboardAPI:
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)}