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
6 changes: 6 additions & 0 deletions elroy/config/paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 0 additions & 1 deletion elroy/core/configs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 0 additions & 4 deletions elroy/core/ctx.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
1 change: 0 additions & 1 deletion elroy/defaults.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 ###
Expand Down
1 change: 1 addition & 0 deletions elroy/repository/feature_requests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Markdown-backed feature request storage and tools."""
93 changes: 93 additions & 0 deletions elroy/repository/feature_requests/queries.py
Original file line number Diff line number Diff line change
@@ -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
200 changes: 200 additions & 0 deletions elroy/repository/feature_requests/store.py
Original file line number Diff line number Diff line change
@@ -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)
Loading