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: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,17 @@
- Added Korean-first premium themes: **벚꽃**, **네오브루탈리즘**, **구찌 스타일**.
- Cherry Blossom theme now supports optional falling petal animation along the sidebar.
- Added favicon assets (`favicon.svg`, `favicon.png`) and hooked them into the app shell.
- Expanded model picker so authenticated OpenAI Codex installs can surface **GPT-5.4**, **GPT-5.4 Mini**, and **Codex Mini** alongside local models like Darwin.
- Expanded model picker so authenticated OpenAI Codex installs can surface **GPT-5.5**, **GPT-5.4**, **GPT-5.4 Mini**, and **Codex Mini** alongside local models like Darwin.
- Improved model routing so choosing GPT-family models from a Darwin-default setup routes through authenticated `openai-codex` instead of hanging on the local backend.
- Added natural-language cron drafting UI with freeform request input, examples, and autofill.
- Added cron preview / validation foundation for vibe-style job creation.
- Added workflow quick actions for common artifact tasks such as workspace summary, note drafting, posting briefs, and follow-up scheduling.
- Added an artifact shelf with create/open/delete flows and a dedicated artifact creation modal with optional AI follow-up assistance.
- Added Setup Packs UI for one-click bootstrap prompts such as Obsidian Starter, ShareNote + Telegram flow, and memory sync guidance.
- Added lightweight Preflight Validator cards plus setup pack run history for more transparent workflow readiness checks.
- Added a main workspace tab switcher so users can alternate between Hermes chat and a live, embedded Paperclip work screen.
- Added a read-only `/api/paperclip/status` probe and `PAPERCLIP_WEB_URL` package guidance for local Paperclip web app connections.
- Updated the desktop install pack zip/docs so installed users know how to configure the live Paperclip tab.

---

Expand Down
7 changes: 5 additions & 2 deletions api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ def _discover_default_workspace() -> Path:
return (STATE_DIR / 'workspace').resolve()

DEFAULT_WORKSPACE = _discover_default_workspace()
DEFAULT_MODEL = os.getenv('HERMES_WEBUI_DEFAULT_MODEL', 'openai/gpt-5.4-mini')
DEFAULT_MODEL = os.getenv('HERMES_WEBUI_DEFAULT_MODEL', 'openai/gpt-5.5')

# ── Startup diagnostics ───────────────────────────────────────────────────────
def print_startup_config() -> None:
Expand Down Expand Up @@ -290,6 +290,7 @@ def verify_hermes_imports() -> tuple:

# Hardcoded fallback models (used when no config.yaml or agent is available)
_FALLBACK_MODELS = [
{'provider': 'OpenAI', 'id': 'openai/gpt-5.5', 'label': 'GPT-5.5'},
{'provider': 'OpenAI', 'id': 'openai/gpt-5.4', 'label': 'GPT-5.4'},
{'provider': 'OpenAI', 'id': 'openai/gpt-5.4-mini', 'label': 'GPT-5.4 Mini'},
{'provider': 'OpenAI', 'id': 'openai/gpt-4o', 'label': 'GPT-4o'},
Expand Down Expand Up @@ -322,13 +323,15 @@ def verify_hermes_imports() -> tuple:
{'id': 'claude-haiku-3-5', 'label': 'Claude Haiku 3.5'},
],
'openai': [
{'id': 'gpt-5.5', 'label': 'GPT-5.5'},
{'id': 'gpt-5.4', 'label': 'GPT-5.4'},
{'id': 'gpt-5.4-mini', 'label': 'GPT-5.4 Mini'},
{'id': 'gpt-4o', 'label': 'GPT-4o'},
{'id': 'o3', 'label': 'o3'},
{'id': 'o4-mini', 'label': 'o4-mini'},
],
'openai-codex': [
{'id': 'gpt-5.5', 'label': 'GPT-5.5'},
{'id': 'gpt-5.4', 'label': 'GPT-5.4'},
{'id': 'gpt-5.4-mini', 'label': 'GPT-5.4 Mini'},
{'id': 'codex-mini-latest', 'label': 'Codex Mini'},
Expand Down Expand Up @@ -408,7 +411,7 @@ def resolve_model_provider(model_id: str) -> tuple:

auth_providers = set((auth_store.get('providers') or {}).keys()) | set((auth_store.get('credential_pool') or {}).keys())

openai_family = {'gpt-5.4', 'gpt-5.4-mini', 'gpt-4o', 'o3', 'o4-mini'}
openai_family = {'gpt-5.5', 'gpt-5.4', 'gpt-5.4-mini', 'gpt-4o', 'o3', 'o4-mini'}
codex_family = {'codex-mini-latest'}
local_family_prefixes = ('Darwin-', 'darwin-', 'mlx-', 'local-')
model_basename = os.path.basename(model_id) if model_id else ''
Expand Down
238 changes: 238 additions & 0 deletions api/facts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
"""
Lightweight scoped fact and memory-candidate store for Hermes WebUI.

Safety rules for Batch 1:
- local JSONL only under ~/.hermes/webui/facts by default;
- no Paperclip writes;
- no Telegram sends;
- no durable Hermes memory writes.
"""
from __future__ import annotations

import json
import os
import threading
import time
import uuid
from pathlib import Path
from typing import Any, Iterable

try:
from api.config import STATE_DIR
except Exception: # pragma: no cover - import fallback for isolated tests
STATE_DIR = Path.home() / '.hermes' / 'webui'

FACTS_DIR = Path(os.getenv('HERMES_WEBUI_FACTS_DIR', str(STATE_DIR / 'facts'))).expanduser().resolve()
CANDIDATES_FILE = 'candidates.jsonl'
FACTS_FILE = 'facts.jsonl'
_LOCK = threading.Lock()

_ALLOWED_CATEGORIES = {'decision', 'preference', 'pattern', 'knowledge', 'constraint'}
_ALLOWED_SCOPES = {'global', 'company', 'project', 'telegram_group', 'workspace', 'profile'}
_ALLOWED_SENSITIVITY = {'public', 'internal', 'confidential'}
_ALLOWED_ACTIONS = {'approve', 'edit', 'reject', 'paperclip_draft_only'}


def _now() -> float:
return time.time()


def _store_dir(store_dir: str | Path | None = None) -> Path:
return Path(store_dir).expanduser().resolve() if store_dir else FACTS_DIR


def _path(name: str, store_dir: str | Path | None = None) -> Path:
base = _store_dir(store_dir)
base.mkdir(parents=True, exist_ok=True)
return base / name


def _read_jsonl(path: Path) -> list[dict[str, Any]]:
if not path.exists():
return []
rows: list[dict[str, Any]] = []
for line in path.read_text(encoding='utf-8').splitlines():
raw = line.strip()
if not raw:
continue
try:
obj = json.loads(raw)
except Exception:
continue
if isinstance(obj, dict):
rows.append(obj)
return rows


def _write_jsonl(path: Path, rows: Iterable[dict[str, Any]]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
text = ''.join(json.dumps(row, ensure_ascii=False, sort_keys=True) + '\n' for row in rows)
path.write_text(text, encoding='utf-8')
Comment on lines +67 to +70
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Make JSONL rewrites atomic.

_write_jsonl() writes straight to the live file. If the process dies mid-write, the candidates file can be truncated, and _read_jsonl() will silently drop the broken rows on the next load. That makes approve/reject capable of corrupting the local store.

Suggested fix
 def _write_jsonl(path: Path, rows: Iterable[dict[str, Any]]) -> None:
     path.parent.mkdir(parents=True, exist_ok=True)
     text = ''.join(json.dumps(row, ensure_ascii=False, sort_keys=True) + '\n' for row in rows)
-    path.write_text(text, encoding='utf-8')
+    tmp = path.with_suffix(path.suffix + '.tmp')
+    tmp.write_text(text, encoding='utf-8')
+    tmp.replace(path)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@api/facts.py` around lines 67 - 70, _write_jsonl currently writes directly to
the target file which can leave it truncated if the process crashes; change it
to write atomically by creating a temp file in the same directory (e.g., using
tempfile.NamedTemporaryFile or a .tmp Path in path.parent), write the JSONL
contents to that temp file, flush and fsync the file, then atomically replace
the target with os.replace(temp_path, path) (and optionally fsync the parent
directory) so readers (_read_jsonl) never see a partially-written file; keep
path.parent.mkdir as is so the temp file can be created.



def _append_jsonl(path: Path, row: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open('a', encoding='utf-8') as fh:
fh.write(json.dumps(row, ensure_ascii=False, sort_keys=True) + '\n')


def _clean_category(value: str | None) -> str:
value = (value or 'knowledge').strip()
return value if value in _ALLOWED_CATEGORIES else 'knowledge'


def _clean_scope(value: str | None) -> str:
value = (value or 'global').strip()
return value if value in _ALLOWED_SCOPES else 'global'


def create_candidate(
category: str,
scope: str,
scope_ref: str,
statement: str,
source_session_id: str | None = None,
source_message_ids: list[str] | None = None,
confidence: float | None = None,
reason: str | None = None,
sensitivity: str = 'internal',
recommended_action: str = 'approve',
metadata: dict[str, Any] | None = None,
store_dir: str | Path | None = None,
) -> dict[str, Any]:
statement = str(statement or '').strip()
if not statement:
raise ValueError('statement is required')
if confidence is None:
confidence = 0.5
confidence = max(0.0, min(1.0, float(confidence)))
sensitivity = sensitivity if sensitivity in _ALLOWED_SENSITIVITY else 'internal'
recommended_action = recommended_action if recommended_action in _ALLOWED_ACTIONS else 'approve'
now = _now()
row = {
'id': 'cand_' + uuid.uuid4().hex[:16],
'category': _clean_category(category),
'scope': _clean_scope(scope),
'scope_ref': str(scope_ref or 'default').strip() or 'default',
'statement': statement,
'source_session_id': source_session_id,
'source_message_ids': source_message_ids or [],
'confidence': confidence,
'sensitivity': sensitivity,
'recommended_action': recommended_action,
'reason': str(reason or '').strip(),
'status': 'pending',
'created_at': now,
'updated_at': now,
'metadata': metadata or {},
'safety': {
'paperclip_write': False,
'telegram_send': False,
'durable_memory_write': False,
},
}
with _LOCK:
_append_jsonl(_path(CANDIDATES_FILE, store_dir), row)
return row


def list_candidates(status: str | None = 'pending', scope: str | None = None, scope_ref: str | None = None, store_dir: str | Path | None = None) -> list[dict[str, Any]]:
rows = _read_jsonl(_path(CANDIDATES_FILE, store_dir))
if status and status != 'all':
rows = [r for r in rows if r.get('status') == status]
if scope:
rows = [r for r in rows if r.get('scope') == scope]
if scope_ref:
rows = [r for r in rows if r.get('scope_ref') == scope_ref]
return sorted(rows, key=lambda r: r.get('created_at', 0), reverse=True)


def _replace_candidate(candidate_id: str, updater, store_dir: str | Path | None = None) -> dict[str, Any]:
path = _path(CANDIDATES_FILE, store_dir)
rows = _read_jsonl(path)
for idx, row in enumerate(rows):
if row.get('id') == candidate_id:
rows[idx] = updater(dict(row))
_write_jsonl(path, rows)
return rows[idx]
raise KeyError('candidate not found')


def approve_candidate(candidate_id: str, edited_statement: str | None = None, store_dir: str | Path | None = None) -> dict[str, Any]:
with _LOCK:
def mark(row):
if row.get('status') != 'pending':
raise ValueError('candidate is not pending')
row['status'] = 'approved'
if edited_statement is not None and str(edited_statement).strip():
row['statement'] = str(edited_statement).strip()
row['edited'] = True
row['updated_at'] = _now()
return row
candidate = _replace_candidate(candidate_id, mark, store_dir)
now = _now()
fact = {
'id': 'fact_' + uuid.uuid4().hex[:16],
'category': candidate.get('category', 'knowledge'),
'scope': candidate.get('scope', 'global'),
'scope_ref': candidate.get('scope_ref', 'default'),
'statement': candidate.get('statement', ''),
'source_candidate_id': candidate.get('id'),
'source_session_id': candidate.get('source_session_id'),
'source_message_ids': candidate.get('source_message_ids', []),
'confidence': candidate.get('confidence'),
'sensitivity': candidate.get('sensitivity', 'internal'),
'status': 'active',
'relations': [],
'created_at': now,
'updated_at': now,
'metadata': candidate.get('metadata', {}),
'safety': {
'paperclip_write': False,
'telegram_send': False,
'durable_memory_write': False,
},
}
_append_jsonl(_path(FACTS_FILE, store_dir), fact)
return {'candidate': candidate, 'fact': fact}


def reject_candidate(candidate_id: str, reason: str | None = None, store_dir: str | Path | None = None) -> dict[str, Any]:
with _LOCK:
def mark(row):
if row.get('status') != 'pending':
raise ValueError('candidate is not pending')
row['status'] = 'rejected'
row['rejection_reason'] = str(reason or '').strip()
row['updated_at'] = _now()
return row
return _replace_candidate(candidate_id, mark, store_dir)


def list_facts(scope: str | None = None, scope_ref: str | None = None, category: str | None = None, query: str | None = None, status: str = 'active', store_dir: str | Path | None = None) -> list[dict[str, Any]]:
rows = _read_jsonl(_path(FACTS_FILE, store_dir))
if status and status != 'all':
rows = [r for r in rows if r.get('status') == status]
if scope:
rows = [r for r in rows if r.get('scope') == scope]
if scope_ref:
rows = [r for r in rows if r.get('scope_ref') == scope_ref]
if category:
rows = [r for r in rows if r.get('category') == category]
if query:
q = query.casefold()
rows = [r for r in rows if q in str(r.get('statement', '')).casefold() or q in str(r.get('scope_ref', '')).casefold()]
return sorted(rows, key=lambda r: r.get('created_at', 0), reverse=True)


def store_summary(store_dir: str | Path | None = None) -> dict[str, Any]:
candidates = _read_jsonl(_path(CANDIDATES_FILE, store_dir))
facts = _read_jsonl(_path(FACTS_FILE, store_dir))
pending = sum(1 for c in candidates if c.get('status') == 'pending')
return {
'store_dir': str(_store_dir(store_dir)),
'candidates': len(candidates),
'pending_candidates': pending,
'facts': len(facts),
'safety': 'local-only; no Paperclip/Telegram/durable-memory writes',
}
17 changes: 10 additions & 7 deletions api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ def __init__(self, session_id: str=None, title: str='Untitled',
self.pinned = bool(pinned)
self.archived = bool(archived)
self.project_id = project_id or None
self.profile = profile
self.profile = profile or 'default'
self.input_tokens = input_tokens or 0
self.output_tokens = output_tokens or 0
self.estimated_cost = estimated_cost
Expand Down Expand Up @@ -109,13 +109,16 @@ def get_session(sid):
return s
raise KeyError(sid)

def new_session(workspace=None, model=None):
def new_session(workspace=None, model=None, profile=None):
# Use _cfg.DEFAULT_MODEL (not the import-time snapshot) so save_settings() changes take effect
try:
from api.profiles import get_active_profile_name
_profile = get_active_profile_name()
except ImportError:
_profile = None
if profile is None:
try:
from api.profiles import get_active_profile_name
_profile = get_active_profile_name()
except ImportError:
_profile = None
else:
_profile = profile
s = Session(workspace=workspace or get_last_workspace(), model=model or _cfg.DEFAULT_MODEL, profile=_profile)
with LOCK:
SESSIONS[s.session_id] = s
Expand Down
12 changes: 7 additions & 5 deletions api/profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,12 +88,14 @@ def get_active_profile_name() -> str:

def get_active_hermes_home() -> Path:
"""Return the HERMES_HOME path for the currently active profile."""
if _active_profile == 'default':
return get_profile_home(_active_profile)


def get_profile_home(name: str | None) -> Path:
"""Return the HERMES_HOME path for the requested profile name."""
if not name or name == 'default':
return _DEFAULT_HERMES_HOME
profile_dir = _DEFAULT_HERMES_HOME / 'profiles' / _active_profile
if profile_dir.is_dir():
return profile_dir
return _DEFAULT_HERMES_HOME
return _DEFAULT_HERMES_HOME / 'profiles' / name


def _set_hermes_home(home: Path):
Expand Down
Loading