From 22fbf155c891c08c04a4aa0346e9f10780e2d84f Mon Sep 17 00:00:00 2001 From: elroy-bot Date: Wed, 29 Apr 2026 14:31:12 -0700 Subject: [PATCH] add feature request flow --- elroy/config/paths.py | 6 + elroy/core/configs.py | 1 - elroy/core/ctx.py | 4 - elroy/defaults.yml | 1 - elroy/repository/feature_requests/__init__.py | 1 + elroy/repository/feature_requests/queries.py | 93 ++++++++ elroy/repository/feature_requests/store.py | 200 ++++++++++++++++++ elroy/repository/feature_requests/tools.py | 160 ++++++++++++++ elroy/repository/user/queries.py | 3 +- elroy/tools/tools_and_commands.py | 8 + tests/fixtures/test_config.yml | 1 - .../repository/feature_requests/test_tools.py | 101 +++++++++ 12 files changed, 571 insertions(+), 8 deletions(-) create mode 100644 elroy/repository/feature_requests/__init__.py create mode 100644 elroy/repository/feature_requests/queries.py create mode 100644 elroy/repository/feature_requests/store.py create mode 100644 elroy/repository/feature_requests/tools.py create mode 100644 tests/repository/feature_requests/test_tools.py diff --git a/elroy/config/paths.py b/elroy/config/paths.py index ee7ae103..77da6a9e 100644 --- a/elroy/config/paths.py +++ b/elroy/config/paths.py @@ -49,3 +49,9 @@ def get_agenda_dir() -> Path: agenda_dir = get_home_dir() / "agenda" agenda_dir.mkdir(parents=True, exist_ok=True) return agenda_dir + + +def get_feature_requests_dir() -> Path: + feature_requests_dir = get_home_dir() / "feature-requests" + feature_requests_dir.mkdir(parents=True, exist_ok=True) + return feature_requests_dir diff --git a/elroy/core/configs.py b/elroy/core/configs.py index 9c6d7e57..8ea9ca0b 100644 --- a/elroy/core/configs.py +++ b/elroy/core/configs.py @@ -89,6 +89,5 @@ class RuntimeConfig: max_ingested_doc_lines: int config_path: str | None = None debug: bool = False - default_persona: str | None = None use_background_threads: bool = True reflect: bool = False diff --git a/elroy/core/ctx.py b/elroy/core/ctx.py index 7be3bd2a..39fb9f55 100644 --- a/elroy/core/ctx.py +++ b/elroy/core/ctx.py @@ -19,7 +19,6 @@ infer_chat_model_name, ) from ..config.paths import get_default_config_path -from ..config.personas import PERSONA from ..db.db_manager import DbManager from ..db.db_session import DbSession from ..llm.client import LlmClient @@ -94,7 +93,6 @@ def __init__( memory_recall_classifier_window: int | None = None, # Basic Configuration debug: bool | None = None, - default_persona: str | None = None, default_assistant_name: str | None = None, use_background_threads: bool | None = None, max_ingested_doc_lines: int | None = None, @@ -178,7 +176,6 @@ def __init__( config_path=config_path, user_token=user_token or "", debug=debug if debug is not None else False, - default_persona=default_persona or PERSONA, default_assistant_name=default_assistant_name or "", use_background_threads=use_background_threads if use_background_threads is not None else True, max_ingested_doc_lines=max_ingested_doc_lines or 0, @@ -194,7 +191,6 @@ def __init__( self.user_token = self.runtime_config.user_token self.show_internal_thought = self.ui_config.show_internal_thought self.default_assistant_name = self.runtime_config.default_assistant_name - self.default_persona = self.runtime_config.default_persona self.debug = self.runtime_config.debug self.max_tokens = self.model_config.max_tokens self.max_assistant_loops = self.runtime_config.max_assistant_loops diff --git a/elroy/defaults.yml b/elroy/defaults.yml index 6730bb36..f9089f09 100644 --- a/elroy/defaults.yml +++ b/elroy/defaults.yml @@ -8,7 +8,6 @@ include_base_tools: true # Whether to include base tools of the assistant. Note: custom_tools_path: null # Path to custom functions to load inline_tool_calls: false # Whether to enable inline tool calls in the assistant (better for some open source models) max_ingested_doc_lines: 1000 # Maximum number of lines to ingest from a document. If a document has more lines, it will be ignored. -default_persona: null # Default system persona for the assistant. See personas.py reflect: false # Whether to reflect on recalled memory content. True leads to richer responses, False leads to faster responses ### Model Selection & Configuration ### diff --git a/elroy/repository/feature_requests/__init__.py b/elroy/repository/feature_requests/__init__.py new file mode 100644 index 00000000..8ee69ea0 --- /dev/null +++ b/elroy/repository/feature_requests/__init__.py @@ -0,0 +1 @@ +"""Markdown-backed feature request storage and tools.""" diff --git a/elroy/repository/feature_requests/queries.py b/elroy/repository/feature_requests/queries.py new file mode 100644 index 00000000..2abb6db1 --- /dev/null +++ b/elroy/repository/feature_requests/queries.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass +from difflib import SequenceMatcher + +from .store import FeatureRequestRecord, feature_requests_dir, load_feature_request + +_TOKEN_RE = re.compile(r"[a-z0-9]+") + + +@dataclass(frozen=True) +class FeatureRequestMatch: + record: FeatureRequestRecord + score: float + reason: str + + +def list_feature_requests() -> list[FeatureRequestRecord]: + return [load_feature_request(path) for path in sorted(feature_requests_dir().glob("*.md"))] + + +def get_feature_request(identifier: str) -> FeatureRequestRecord | None: + normalized_identifier = _normalize(identifier) + for record in list_feature_requests(): + candidates = { + record.request_id, + record.title, + record.path.stem, + *record.aliases, + } + if normalized_identifier in {_normalize(candidate) for candidate in candidates}: + return record + return None + + +def _normalize(text: str) -> str: + return " ".join(_TOKEN_RE.findall(text.lower())) + + +def _token_set(text: str) -> set[str]: + return set(_TOKEN_RE.findall(text.lower())) + + +def _title_score(candidate: str, existing: str) -> float: + return SequenceMatcher(None, _normalize(candidate), _normalize(existing)).ratio() + + +def _token_overlap(candidate: str, existing: str) -> float: + candidate_tokens = _token_set(candidate) + existing_tokens = _token_set(existing) + if not candidate_tokens or not existing_tokens: + return 0.0 + intersection = len(candidate_tokens & existing_tokens) + return intersection / max(len(candidate_tokens), len(existing_tokens)) + + +def _score_match(title: str, description: str, record: FeatureRequestRecord) -> FeatureRequestMatch: + existing_titles = [record.title, *record.aliases] + title_scores = [_title_score(title, existing_title) for existing_title in existing_titles] + best_title_score = max(title_scores, default=0.0) + summary_score = _title_score(description, record.summary) + overlap_score = max((_token_overlap(title, existing_title) for existing_title in existing_titles), default=0.0) + combined = max(best_title_score, (best_title_score * 0.7) + (summary_score * 0.15) + (overlap_score * 0.15)) + if best_title_score >= 0.995: + reason = "exact title match" + elif best_title_score >= 0.92: + reason = "very similar title" + elif best_title_score >= 0.8 and overlap_score >= 0.6: + reason = "strong title overlap" + else: + reason = "weak match" + return FeatureRequestMatch(record=record, score=combined, reason=reason) + + +def find_best_feature_request_match(title: str, description: str) -> FeatureRequestMatch | None: + matches = [_score_match(title, description, record) for record in list_feature_requests()] + if not matches: + return None + best_match = max(matches, key=lambda match: match.score) + if best_match.score >= 0.92: + return best_match + if best_match.score >= 0.82 and best_match.reason == "strong title overlap": + return best_match + description_similarity = _title_score(description, best_match.record.summary) + title_overlap = _token_overlap(title, best_match.record.title) + if description_similarity >= 0.72 and title_overlap >= 0.25: + return FeatureRequestMatch( + record=best_match.record, + score=best_match.score, + reason="similar behavior description", + ) + return None diff --git a/elroy/repository/feature_requests/store.py b/elroy/repository/feature_requests/store.py new file mode 100644 index 00000000..0a9d9bd4 --- /dev/null +++ b/elroy/repository/feature_requests/store.py @@ -0,0 +1,200 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path + +import yaml + +from ...config.paths import get_feature_requests_dir +from ...repository.file_utils import sanitize_filename +from ...utils.clock import utc_now + + +@dataclass(frozen=True) +class FeatureRequestRecord: + path: Path + request_id: str + title: str + status: str + created_at: str + updated_at: str + aliases: tuple[str, ...] + summary: str + rationale: str | None + supporting_context: str | None + + +@dataclass(frozen=True) +class FeatureRequestFrontmatter: + request_id: str + title: str + status: str + created_at: str | datetime + updated_at: str | datetime + aliases: list[str] + + +def feature_requests_dir() -> Path: + path = get_feature_requests_dir() + path.mkdir(parents=True, exist_ok=True) + return path + + +def slugify_feature_request_title(title: str) -> str: + return sanitize_filename(title.strip().lower().replace("_", "-").replace(" ", "-"), fallback="feature-request") + + +def _stringify_timestamp(value: str | datetime) -> str: + if isinstance(value, datetime): + return value.isoformat() + return value + + +def _body(summary: str, rationale: str | None, supporting_context: str | None) -> str: + sections = ["## Summary", summary.strip()] + if rationale: + sections.extend(["", "## Why It Matters", rationale.strip()]) + if supporting_context: + sections.extend(["", "## Supporting Context", supporting_context.strip()]) + return "\n".join(sections).strip() + "\n" + + +def build_feature_request_content( + frontmatter: FeatureRequestFrontmatter, + summary: str, + rationale: str | None, + supporting_context: str | None, +) -> str: + frontmatter_data = { + "id": frontmatter.request_id, + "title": frontmatter.title, + "status": frontmatter.status, + "created_at": _stringify_timestamp(frontmatter.created_at), + "updated_at": _stringify_timestamp(frontmatter.updated_at), + "aliases": frontmatter.aliases, + } + frontmatter_str = yaml.safe_dump(frontmatter_data, default_flow_style=False, sort_keys=False).strip() + return f"---\n{frontmatter_str}\n---\n\n{_body(summary, rationale, supporting_context)}" + + +def feature_request_path(title: str, existing_paths: set[Path] | None = None) -> Path: + if existing_paths is None: + existing_paths = set(feature_requests_dir().glob("*.md")) + base = slugify_feature_request_title(title) + candidate = feature_requests_dir() / f"{base}.md" + if candidate not in existing_paths: + return candidate + counter = 2 + while True: + candidate = feature_requests_dir() / f"{base}-{counter}.md" + if candidate not in existing_paths: + return candidate + counter += 1 + + +def write_new_feature_request( + *, + title: str, + summary: str, + rationale: str | None, + supporting_context: str | None, +) -> FeatureRequestRecord: + now = utc_now().isoformat() + request_id = slugify_feature_request_title(title) + path = feature_request_path(title) + path.write_text( + build_feature_request_content( + FeatureRequestFrontmatter( + request_id=request_id, + title=title, + status="open", + created_at=now, + updated_at=now, + aliases=[], + ), + summary, + rationale, + supporting_context, + ), + encoding="utf-8", + ) + return load_feature_request(path) + + +def load_feature_request(path: Path) -> FeatureRequestRecord: + from ...repository.file_utils import read_file_text, read_frontmatter + + frontmatter = read_frontmatter(path) + body = read_file_text(path) + summary, rationale, supporting_context = parse_feature_request_body(body) + aliases = tuple(str(alias) for alias in frontmatter.get("aliases", []) if str(alias).strip()) + return FeatureRequestRecord( + path=path, + request_id=str(frontmatter.get("id") or path.stem), + title=str(frontmatter.get("title") or path.stem), + status=str(frontmatter.get("status") or "open"), + created_at=str(frontmatter.get("created_at") or ""), + updated_at=str(frontmatter.get("updated_at") or ""), + aliases=aliases, + summary=summary, + rationale=rationale, + supporting_context=supporting_context, + ) + + +def parse_feature_request_body(body: str) -> tuple[str, str | None, str | None]: + sections: dict[str, list[str]] = {} + current_section: str | None = None + for line in body.splitlines(): + if line.startswith("## "): + current_section = line[3:].strip() + sections.setdefault(current_section, []) + continue + if current_section is not None: + sections[current_section].append(line) + + def _clean(section_name: str) -> str | None: + value = "\n".join(sections.get(section_name, [])).strip() + return value or None + + summary = _clean("Summary") or body.strip() + rationale = _clean("Why It Matters") + supporting_context = _clean("Supporting Context") + return summary, rationale, supporting_context + + +def update_feature_request( + record: FeatureRequestRecord, + *, + title: str | None = None, + status: str | None = None, + aliases: list[str] | None = None, + summary: str | None = None, + rationale: str | None = None, + supporting_context: str | None = None, +) -> FeatureRequestRecord: + updated_title = title or record.title + updated_status = status or record.status + updated_summary = summary or record.summary + updated_rationale = rationale if rationale is not None else record.rationale + updated_supporting_context = supporting_context if supporting_context is not None else record.supporting_context + updated_aliases = aliases if aliases is not None else list(record.aliases) + updated_at = utc_now().isoformat() + record.path.write_text( + build_feature_request_content( + FeatureRequestFrontmatter( + request_id=record.request_id, + title=updated_title, + status=updated_status, + created_at=record.created_at or updated_at, + updated_at=updated_at, + aliases=updated_aliases, + ), + updated_summary, + updated_rationale, + updated_supporting_context, + ), + encoding="utf-8", + ) + return load_feature_request(record.path) diff --git a/elroy/repository/feature_requests/tools.py b/elroy/repository/feature_requests/tools.py new file mode 100644 index 00000000..056e4161 --- /dev/null +++ b/elroy/repository/feature_requests/tools.py @@ -0,0 +1,160 @@ +from ...core.constants import tool +from ...core.ctx import ElroyContext +from ...utils.clock import utc_now +from .queries import ( + find_best_feature_request_match, + get_feature_request, +) +from .queries import ( + list_feature_requests as list_feature_request_records, +) +from .store import update_feature_request, write_new_feature_request + + +def _build_supporting_context(ctx: ElroyContext, title: str, description: str, rationale: str | None) -> str: + lines = [ + f"- Captured at: {utc_now().isoformat()}", + f"- Requested title: {title}", + f"- Description: {description.strip()}", + ] + if rationale: + lines.append(f"- Rationale: {rationale.strip()}") + lines.append(f"- User token: {ctx.user_token}") + return "\n".join(lines) + + +def _merge_supporting_context(existing: str | None, new_context: str) -> str: + if not existing: + return new_context + if new_context in existing: + return existing + return f"{existing.rstrip()}\n\n{new_context}" + + +def _normalize_optional(value: str | None) -> str | None: + if value is None: + return None + stripped = value.strip() + return stripped or None + + +@tool +def list_feature_requests(ctx: ElroyContext) -> str: + """List the current markdown-backed feature requests. + + Use this before creating a new feature request when you suspect the request may + already exist. This helps avoid duplicate backlog entries by surfacing the + canonical titles, aliases, statuses, and brief summaries of existing requests. + + Args: + ctx (ElroyContext): Active Elroy context. Present for tool compatibility. + + Returns: + str: A compact text listing of all current feature requests. + """ + _ = ctx + records = list_feature_request_records() + if not records: + return "No feature requests found." + + lines = [f"Feature requests ({len(records)}):", ""] + for record in records: + aliases = f" | aliases: {', '.join(record.aliases)}" if record.aliases else "" + lines.append(f"- {record.title} [{record.status}] ({record.path.name}){aliases}") + lines.append(f" Summary: {record.summary}") + return "\n".join(lines) + + +@tool +def edit_feature_request( + ctx: ElroyContext, + identifier: str, + title: str | None = None, + description: str | None = None, + rationale: str | None = None, + status: str | None = None, +) -> str: + """Edit an existing markdown feature request. + + Use this when a feature request already exists and needs a better title, + refined summary, updated rationale, or status change. Prefer this over creating + a duplicate request for the same product need. + + Args: + ctx (ElroyContext): Active Elroy context used to capture lightweight edit provenance. + identifier (str): Existing request id, title, alias, or filename stem to update. + title (str | None): Replacement canonical title. + description (str | None): Replacement summary of the request. + rationale (str | None): Replacement rationale for why it matters. + status (str | None): Replacement status value such as `open` or `closed`. + + Returns: + str: Confirmation describing the updated feature request. + """ + record = get_feature_request(identifier.strip()) + if record is None: + return f"Feature request '{identifier}' not found." + + cleaned_title = _normalize_optional(title) + cleaned_description = _normalize_optional(description) + cleaned_rationale = _normalize_optional(rationale) + cleaned_status = _normalize_optional(status) + updated_record = update_feature_request( + record, + title=cleaned_title, + status=cleaned_status, + summary=cleaned_description, + rationale=cleaned_rationale, + supporting_context=_merge_supporting_context( + record.supporting_context, + "\n".join( + [ + f"- Edited at: {utc_now().isoformat()}", + f"- Edited by user token: {ctx.user_token}", + ] + ), + ), + ) + return f"Updated feature request: {updated_record.title} ({updated_record.path.name})." + + +@tool +def make_feature_request(ctx: ElroyContext, title: str, description: str, rationale: str | None = None) -> str: + """Create or merge a markdown feature request for future product work. + + Use this when the user asks for a net new capability, workflow, or product behavior + that Elroy does not currently support. First consider calling `list_feature_requests` + to inspect the existing backlog. This tool will also try to merge into an existing + feature request when the request appears duplicative, to avoid backlog bloat. + + Args: + ctx (ElroyContext): Active Elroy context used to capture lightweight request provenance. + title (str): Short title describing the requested feature. + description (str): What the new feature should do. + rationale (str | None): Optional explanation of why the feature matters. + + Returns: + str: Confirmation describing whether a request was created or merged. + """ + + cleaned_title = title.strip() + cleaned_description = description.strip() + cleaned_rationale = rationale.strip() if rationale else None + supporting_context = _build_supporting_context(ctx, cleaned_title, cleaned_description, cleaned_rationale) + + if match := find_best_feature_request_match(cleaned_title, cleaned_description): + aliases = sorted({*match.record.aliases, cleaned_title} - {match.record.title}) + updated_record = update_feature_request( + match.record, + aliases=aliases, + supporting_context=_merge_supporting_context(match.record.supporting_context, supporting_context), + ) + return f"Merged into existing feature request: {updated_record.title} ({updated_record.path.name}; match reason: {match.reason})." + + new_record = write_new_feature_request( + title=cleaned_title, + summary=cleaned_description, + rationale=cleaned_rationale, + supporting_context=supporting_context, + ) + return f"Created feature request: {new_record.title} ({new_record.path.name})." diff --git a/elroy/repository/user/queries.py b/elroy/repository/user/queries.py index 619ba935..f5447c56 100644 --- a/elroy/repository/user/queries.py +++ b/elroy/repository/user/queries.py @@ -1,5 +1,6 @@ from sqlmodel import Session, select +from ...config.personas import PERSONA from ...core.constants import ( ASSISTANT_ALIAS_STRING, DEFAULT_USER_NAME, @@ -36,7 +37,7 @@ def get_persona(ctx: ElroyContext): """ user_preference = UserPreferenceStore(require_db_session(ctx), ctx.user_id).get_or_create_user_preference() - raw_persona = user_preference.system_persona or ctx.default_persona or "" + raw_persona = user_preference.system_persona or PERSONA user_noun = user_preference.preferred_name or "my user" return raw_persona.replace(USER_ALIAS_STRING, user_noun).replace(ASSISTANT_ALIAS_STRING, get_assistant_name(ctx)) diff --git a/elroy/tools/tools_and_commands.py b/elroy/tools/tools_and_commands.py index 142240a2..c4a69dfa 100644 --- a/elroy/tools/tools_and_commands.py +++ b/elroy/tools/tools_and_commands.py @@ -24,6 +24,11 @@ refresh_system_instructions, reset_messages, ) +from ..repository.feature_requests.tools import ( + edit_feature_request, + list_feature_requests, + make_feature_request, +) from ..repository.memories.tools import ( create_memory, examine_memories, @@ -97,6 +102,9 @@ set_user_preferred_name, get_current_date, get_fast_recall, + edit_feature_request, + list_feature_requests, + make_feature_request, } USER_ONLY_COMMANDS = { tail_elroy_logs, diff --git a/tests/fixtures/test_config.yml b/tests/fixtures/test_config.yml index ba446808..2c324e3d 100644 --- a/tests/fixtures/test_config.yml +++ b/tests/fixtures/test_config.yml @@ -1,3 +1,2 @@ chat_model: gpt-5-nano -default_persona: You are a helpful assistant, your name is Jimbo irrelevant_key: This should not crash the app diff --git a/tests/repository/feature_requests/test_tools.py b/tests/repository/feature_requests/test_tools.py new file mode 100644 index 00000000..62fe5472 --- /dev/null +++ b/tests/repository/feature_requests/test_tools.py @@ -0,0 +1,101 @@ +from pathlib import Path + +from elroy.repository.feature_requests.queries import list_feature_requests +from elroy.repository.feature_requests.tools import ( + edit_feature_request, + make_feature_request, +) +from elroy.repository.feature_requests.tools import ( + list_feature_requests as list_feature_requests_tool, +) + + +def test_make_feature_request_creates_markdown_file(ctx, monkeypatch, tmp_path): + monkeypatch.setenv("ELROY_HOME", str(tmp_path)) + + response = make_feature_request( + ctx, + "Add calendar sync", + "Allow Elroy to sync reminders with an external calendar provider.", + "This would make reminders useful outside the terminal.", + ) + + feature_request_dir = Path(tmp_path) / "feature-requests" + files = list(feature_request_dir.glob("*.md")) + + assert "Created feature request: Add calendar sync" in response + assert len(files) == 1 + content = files[0].read_text(encoding="utf-8") + assert "title: Add calendar sync" in content + assert "## Summary" in content + assert "## Why It Matters" in content + assert "## Supporting Context" in content + + +def test_make_feature_request_merges_similar_requests(ctx, monkeypatch, tmp_path): + monkeypatch.setenv("ELROY_HOME", str(tmp_path)) + + make_feature_request( + ctx, + "Add calendar sync", + "Allow Elroy to sync reminders with external calendar providers.", + "Useful for integrating reminders into existing workflows.", + ) + response = make_feature_request( + ctx, + "Calendar syncing for reminders", + "Allow reminders to appear in external calendar providers.", + "Avoid maintaining the same reminder in two places.", + ) + + records = list_feature_requests() + + assert "Merged into existing feature request: Add calendar sync" in response + assert len(records) == 1 + assert "Calendar syncing for reminders" in records[0].aliases + assert "Avoid maintaining the same reminder in two places." in (records[0].supporting_context or "") + + +def test_list_feature_requests_tool_returns_compact_listing(ctx, monkeypatch, tmp_path): + monkeypatch.setenv("ELROY_HOME", str(tmp_path)) + + make_feature_request( + ctx, + "Add calendar sync", + "Allow Elroy to sync reminders with external calendar providers.", + "Useful for integrating reminders into existing workflows.", + ) + + result = list_feature_requests_tool(ctx) + + assert "Feature requests (1):" in result + assert "- Add calendar sync [open]" in result + assert "Summary: Allow Elroy to sync reminders with external calendar providers." in result + + +def test_edit_feature_request_updates_existing_request(ctx, monkeypatch, tmp_path): + monkeypatch.setenv("ELROY_HOME", str(tmp_path)) + + make_feature_request( + ctx, + "Add calendar sync", + "Allow Elroy to sync reminders with external calendar providers.", + "Useful for integrating reminders into existing workflows.", + ) + + response = edit_feature_request( + ctx, + "Add calendar sync", + title="Add calendar integration", + description="Allow Elroy reminders to sync with external calendars.", + rationale="This would reduce duplicated reminder management.", + status="planned", + ) + records = list_feature_requests() + + assert "Updated feature request: Add calendar integration" in response + assert len(records) == 1 + assert records[0].title == "Add calendar integration" + assert records[0].summary == "Allow Elroy reminders to sync with external calendars." + assert records[0].rationale == "This would reduce duplicated reminder management." + assert records[0].status == "planned"