HumeraR's picture
feat: add MemoryNotes component to Settings page and enhance UI styles
b74c6ae
Raw History Blame Contribute Delete
4.86 kB
"""The writer's standing notes ("audience: indie founders"), one JSON row per workspace.
Every chat loads them as background for tone and focus. They are never evidence: only posts are, and
citations still get checked. Notes come from the writer (Settings) or from the memory job, which reads
the writer's own messages and decides what is worth keeping. Notes the writer typed are never changed
by the job."""
import re
import uuid
from datetime import UTC, datetime
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.models import WorkspaceMemory
MAX_FACTS = 20
MAX_KEY = 60
MAX_VALUE = 500
PROFILE_CHARS = 1500
def normalize_key(key: str) -> str:
return re.sub(r"[^a-z0-9]+", "-", key.strip().lower()).strip("-")[:MAX_KEY]
def _now() -> str:
return datetime.now(UTC).isoformat()
def _row(db: Session, workspace_id: uuid.UUID, *, lock: bool = False, create: bool = False) -> WorkspaceMemory | None:
"""The workspace's row. lock=True holds it (FOR UPDATE on Postgres) so two writers cannot overwrite each other."""
q = select(WorkspaceMemory).where(WorkspaceMemory.workspace_id == workspace_id)
row = db.scalar(q.with_for_update() if lock else q)
if row or not create:
return row
db.add(WorkspaceMemory(workspace_id=workspace_id, facts={}))
try:
db.flush()
except IntegrityError: # another request created it first
db.rollback()
return db.scalar(q.with_for_update() if lock else q)
def _item(key: str, fact: dict) -> dict:
return {"key": key, "value": fact.get("value", ""), "source": fact.get("source", "user"),
"updated_at": fact.get("updated_at")}
def list_facts(db: Session, workspace_id: uuid.UUID) -> list[dict]:
row = _row(db, workspace_id)
items = [_item(k, f) for k, f in (row.facts or {}).items()] if row else []
return sorted(items, key=lambda i: i["updated_at"] or "", reverse=True)
def set_fact(db: Session, workspace_id: uuid.UUID, key: str, value: str, source: str = "user") -> dict:
"""Create or replace one note (the writer typing in Settings). Raises 422 on bad input."""
key, value = normalize_key(key), value.strip()
if not key:
raise HTTPException(422, "Give the note a short name")
if not value:
raise HTTPException(422, "The note cannot be empty")
if len(value) > MAX_VALUE:
raise HTTPException(422, f"Keep each note under {MAX_VALUE} characters")
row = _row(db, workspace_id, lock=True, create=True)
facts = dict(row.facts or {})
if key not in facts and len(facts) >= MAX_FACTS:
raise HTTPException(422, f"You can keep up to {MAX_FACTS} notes. Delete one to add another.")
facts[key] = {"value": value, "source": source, "updated_at": _now()}
row.facts = facts # a new dict, so the JSON column is saved
db.commit()
return _item(key, facts[key])
def delete_fact(db: Session, workspace_id: uuid.UUID, key: str) -> bool:
row = _row(db, workspace_id, lock=True)
key = normalize_key(key)
if not row or key not in (row.facts or {}):
return False
row.facts = {k: v for k, v in row.facts.items() if k != key}
db.commit()
return True
def apply_operations(db: Session, workspace_id: uuid.UUID, ops: list[dict]) -> list[dict]:
"""Apply the memory job's add/update/delete decisions. Bad or unsafe ones are skipped, never raised:
notes the writer typed stay as they are, the cap holds, and repeats change nothing. Returns what changed."""
row = _row(db, workspace_id, lock=True, create=True)
facts = dict(row.facts or {})
applied: list[dict] = []
for op in ops:
kind, key = op.get("op"), normalize_key(str(op.get("key") or ""))
value = str(op.get("value") or "").strip()
current = facts.get(key)
if not key or (current and current.get("source") == "user"):
continue
if kind in ("add", "update"):
if not value or len(value) > MAX_VALUE or (current and current.get("value") == value):
continue
if current is None and len(facts) >= MAX_FACTS:
continue
facts[key] = {"value": value, "source": "auto", "updated_at": _now()}
applied.append({"op": "update" if current else "add", "key": key, "value": value})
elif kind == "delete" and current:
del facts[key]
applied.append({"op": "delete", "key": key})
if applied:
row.facts = facts
db.commit()
return applied
def profile_text(items: list[dict]) -> str:
"""Prompt block for the agents: one line per note, capped so the prompt stays small."""
if not items:
return "(none)"
return "\n".join(f"- {i['key']}: {i['value']}" for i in items)[:PROFILE_CHARS]