Compare commits
3 Commits
5176c706b6
...
fef45b3e05
| Author | SHA1 | Date | |
|---|---|---|---|
| fef45b3e05 | |||
| 5dbcfc7ccf | |||
| 951788f9ec |
@@ -26,3 +26,17 @@ LYRA_DB_PATH=data/lyra.db
|
|||||||
# Optional: run embeddings on a separate always-on Ollama (decoupled from
|
# Optional: run embeddings on a separate always-on Ollama (decoupled from
|
||||||
# LOCAL_BASE_URL, which serves local chat). Defaults to LOCAL_BASE_URL if unset.
|
# LOCAL_BASE_URL, which serves local chat). Defaults to LOCAL_BASE_URL if unset.
|
||||||
# EMBED_BASE_URL=http://127.0.0.1:11434
|
# EMBED_BASE_URL=http://127.0.0.1:11434
|
||||||
|
|
||||||
|
# --- Thought-loop reach-out (ntfy push) ---
|
||||||
|
# Leave NTFY_URL empty to disable proactive pings entirely.
|
||||||
|
NTFY_URL=
|
||||||
|
NTFY_TOPIC=lyra
|
||||||
|
LYRA_WEB_URL=
|
||||||
|
PING_SALIENCE=0.7 # min thought salience to push (eager)
|
||||||
|
PING_COOLDOWN_MIN=0 # min minutes between pushes (0 = none)
|
||||||
|
PING_QUIET_HOURS=1-9 # local hours to stay silent
|
||||||
|
LYRA_TIMEZONE=America/New_York
|
||||||
|
|
||||||
|
# --- External input feeds (RSS/Atom, comma-separated) ---
|
||||||
|
LYRA_FEEDS=https://hnrss.org/frontpage,https://www.pokernews.com/rss.php
|
||||||
|
FEED_REACT_PROB=0.5 # chance a new thought reacts to a feed item
|
||||||
|
|||||||
@@ -60,6 +60,27 @@ def _detail_note(exchanges: list[memory.Exchange]) -> Message:
|
|||||||
return {"role": "system", "content": body}
|
return {"role": "system", "content": body}
|
||||||
|
|
||||||
|
|
||||||
|
def _inner_life_note() -> Message | None:
|
||||||
|
"""One coherent window onto what she's been doing on her own since last time —
|
||||||
|
the threads she's turning over plus the things she's written for herself. Sits
|
||||||
|
with her self-state so chat reads as a continuous mind, not a fresh boot. The
|
||||||
|
persona tells her to weave this in naturally when it fits."""
|
||||||
|
parts: list[str] = []
|
||||||
|
threads = thoughts.context_note() # active threads, with their latest thought
|
||||||
|
if threads:
|
||||||
|
parts.append(threads)
|
||||||
|
wrote = memory.list_journal(limit=3, kinds=("journal", "note"))
|
||||||
|
if wrote:
|
||||||
|
lines = "\n".join(f"- ({w['created_at'][:10]}) {w['content']}" for w in reversed(wrote))
|
||||||
|
parts.append(
|
||||||
|
"Things you've written in your journal lately (yours — you can refer back "
|
||||||
|
"to them if they're relevant):\n" + lines
|
||||||
|
)
|
||||||
|
if not parts:
|
||||||
|
return None
|
||||||
|
return {"role": "system", "content": "\n\n".join(parts)}
|
||||||
|
|
||||||
|
|
||||||
def _now_note() -> Message:
|
def _now_note() -> Message:
|
||||||
"""Current wall-clock time + how long since Brian last said anything.
|
"""Current wall-clock time + how long since Brian last said anything.
|
||||||
|
|
||||||
@@ -89,6 +110,14 @@ def build_messages(session_id: str, user_msg: str,
|
|||||||
# right after the persona — her sense of self before her model of the world.
|
# right after the persona — her sense of self before her model of the world.
|
||||||
messages.append({"role": "system", "content": self_state.render_for_context(self_state.load())})
|
messages.append({"role": "system", "content": self_state.render_for_context(self_state.load())})
|
||||||
|
|
||||||
|
# Her ongoing inner life — the threads she's turning over and what she's written
|
||||||
|
# for herself — so she's continuous across conversations and can pick up where she
|
||||||
|
# left off, not only when a thought crosses the surface bar below. Rides with the
|
||||||
|
# self; the persona tells her to bring it into conversation naturally when it fits.
|
||||||
|
inner = _inner_life_note()
|
||||||
|
if inner:
|
||||||
|
messages.append(inner)
|
||||||
|
|
||||||
# Mode card: how to behave *right now* (e.g. live-cash copilot). High priority —
|
# Mode card: how to behave *right now* (e.g. live-cash copilot). High priority —
|
||||||
# it sits just after her sense of self, before her model of the world. Talk mode
|
# it sits just after her sense of self, before her model of the world. Talk mode
|
||||||
# has no card (the persona's default voice is the Talk register).
|
# has no card (the persona's default voice is the Talk register).
|
||||||
|
|||||||
@@ -25,6 +25,22 @@ class Config:
|
|||||||
embed_base_url: str # Ollama endpoint for embeddings (own box, decoupled from local chat)
|
embed_base_url: str # Ollama endpoint for embeddings (own box, decoupled from local chat)
|
||||||
summary_backend: str # "local" or "cloud" — backend used to compact memory
|
summary_backend: str # "local" or "cloud" — backend used to compact memory
|
||||||
db_path: Path
|
db_path: Path
|
||||||
|
# Proactive reach-out (ntfy push). Empty ntfy_url disables pinging.
|
||||||
|
ntfy_url: str # base url, e.g. "http://10.0.0.41:8090"
|
||||||
|
ntfy_topic: str # topic to publish to, e.g. "lyra"
|
||||||
|
web_url: str # base url of the Lyra web app, for push tap-through links
|
||||||
|
timezone: str # IANA tz for quiet hours / local time
|
||||||
|
ping_salience: float # min thought salience to push (eager = ~0.7)
|
||||||
|
ping_cooldown_min: int # min minutes between pushes (eager = 0)
|
||||||
|
ping_quiet_hours: str # local "start-end" 24h window to stay silent, e.g. "1-9"
|
||||||
|
# External input feed (her #1: react to the world). Comma-separated RSS/Atom URLs.
|
||||||
|
feeds: tuple[str, ...]
|
||||||
|
feed_react_prob: float # chance a would-be new thread reacts to a feed item instead
|
||||||
|
|
||||||
|
|
||||||
|
def _csv(name: str, default: str) -> tuple[str, ...]:
|
||||||
|
raw = os.getenv(name, default)
|
||||||
|
return tuple(u.strip() for u in raw.split(",") if u.strip())
|
||||||
|
|
||||||
|
|
||||||
def load() -> Config:
|
def load() -> Config:
|
||||||
@@ -44,4 +60,13 @@ def load() -> Config:
|
|||||||
embed_base_url=os.getenv("EMBED_BASE_URL", os.getenv("LOCAL_BASE_URL", "http://localhost:11434")),
|
embed_base_url=os.getenv("EMBED_BASE_URL", os.getenv("LOCAL_BASE_URL", "http://localhost:11434")),
|
||||||
summary_backend=os.getenv("SUMMARY_BACKEND", "local").lower(),
|
summary_backend=os.getenv("SUMMARY_BACKEND", "local").lower(),
|
||||||
db_path=Path(os.getenv("LYRA_DB_PATH", "data/lyra.db")),
|
db_path=Path(os.getenv("LYRA_DB_PATH", "data/lyra.db")),
|
||||||
|
ntfy_url=os.getenv("NTFY_URL", "").rstrip("/"),
|
||||||
|
ntfy_topic=os.getenv("NTFY_TOPIC", "lyra"),
|
||||||
|
web_url=os.getenv("LYRA_WEB_URL", "").rstrip("/"),
|
||||||
|
timezone=os.getenv("LYRA_TIMEZONE", "America/New_York"),
|
||||||
|
ping_salience=float(os.getenv("PING_SALIENCE", "0.7")),
|
||||||
|
ping_cooldown_min=int(os.getenv("PING_COOLDOWN_MIN", "0")),
|
||||||
|
ping_quiet_hours=os.getenv("PING_QUIET_HOURS", "1-9"),
|
||||||
|
feeds=_csv("LYRA_FEEDS", "https://hnrss.org/frontpage,https://www.pokernews.com/rss.php"),
|
||||||
|
feed_react_prob=float(os.getenv("FEED_REACT_PROB", "0.5")),
|
||||||
)
|
)
|
||||||
|
|||||||
+11
-1
@@ -25,7 +25,7 @@ import argparse
|
|||||||
import time
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from lyra import config, era, logbus, memory, narrative, profile, self_state, summary, thoughts
|
from lyra import config, era, feeds, logbus, memory, narrative, profile, self_state, summary, thoughts
|
||||||
from lyra.llm import Backend
|
from lyra.llm import Backend
|
||||||
from lyra.summary import SUMMARIZE_AFTER
|
from lyra.summary import SUMMARIZE_AFTER
|
||||||
|
|
||||||
@@ -78,6 +78,16 @@ def dream_cycle(backend: Backend | None = None, force: bool = False) -> dict:
|
|||||||
logbus.log("info", "dream cycle sensing", ripe=backlog["ripe"], dirty=backlog["dirty"],
|
logbus.log("info", "dream cycle sensing", ripe=backlog["ripe"], dirty=backlog["dirty"],
|
||||||
profile_lag=profile_lag, new_activity=new_activity, drives=_round(drives))
|
profile_lag=profile_lag, new_activity=new_activity, drives=_round(drives))
|
||||||
|
|
||||||
|
# Thought-loop housekeeping (no LLM): rest stale threads so the open-thread cap
|
||||||
|
# never jams and the feed stays current. Cheap; run every pass.
|
||||||
|
thoughts.decay()
|
||||||
|
# Pull external feeds on the cycle cadence (~30 min) so she has fresh items from
|
||||||
|
# the world to react to. Network-only; failures degrade to no new items.
|
||||||
|
try:
|
||||||
|
feeds.refresh()
|
||||||
|
except Exception as exc:
|
||||||
|
logbus.log("error", "feed refresh failed", error=str(exc)[:160])
|
||||||
|
|
||||||
actions: list[str] = []
|
actions: list[str] = []
|
||||||
|
|
||||||
# --- continuity: compact raw sessions into gists ---
|
# --- continuity: compact raw sessions into gists ---
|
||||||
|
|||||||
+133
@@ -0,0 +1,133 @@
|
|||||||
|
"""External input stream: RSS/Atom feeds Lyra reacts to (her thought-loop #1).
|
||||||
|
|
||||||
|
Her own sketch wanted the loop fed by "external data feeds relevant to your
|
||||||
|
interests (poker articles, tech news)" — so her thoughts aren't only about her own
|
||||||
|
interior. This pulls configured feeds, remembers what it's seen, and hands the
|
||||||
|
thought loop one fresh item at a time to react to (see `thoughts.think` react mode).
|
||||||
|
|
||||||
|
Feeds are configurable (`LYRA_FEEDS`, comma-separated URLs). Parsing is stdlib
|
||||||
|
ElementTree — tolerant of both RSS 2.0 and Atom, namespaces stripped — so there's
|
||||||
|
no new dependency. Network failures degrade to "no item this pass", never raise.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from lyra import clock, config, logbus, memory
|
||||||
|
|
||||||
|
_SCHEMA = """
|
||||||
|
CREATE TABLE IF NOT EXISTS feed_items (
|
||||||
|
id TEXT PRIMARY KEY, -- guid/link, stable per item
|
||||||
|
feed TEXT,
|
||||||
|
title TEXT,
|
||||||
|
link TEXT,
|
||||||
|
summary TEXT,
|
||||||
|
seen_at TEXT NOT NULL,
|
||||||
|
used INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_feed_items_used ON feed_items(used);
|
||||||
|
"""
|
||||||
|
|
||||||
|
_ensured_for = None
|
||||||
|
_UA = {"User-Agent": "Lyra/0.3 (+thought-loop feed reader)"}
|
||||||
|
_MAX_SUMMARY = 600
|
||||||
|
|
||||||
|
|
||||||
|
def _c():
|
||||||
|
global _ensured_for
|
||||||
|
conn = memory._connection()
|
||||||
|
if _ensured_for is not conn:
|
||||||
|
conn.executescript(_SCHEMA)
|
||||||
|
_ensured_for = conn
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def _local(tag: str) -> str:
|
||||||
|
return tag.rsplit("}", 1)[-1].lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _text(el) -> str:
|
||||||
|
return (el.text or "").strip() if el is not None else ""
|
||||||
|
|
||||||
|
|
||||||
|
def parse(xml: bytes, feed_url: str = "") -> list[dict]:
|
||||||
|
"""Tolerant RSS-2.0 / Atom parse -> [{id,title,link,summary}]. Empty on garbage."""
|
||||||
|
try:
|
||||||
|
root = ET.fromstring(xml)
|
||||||
|
except ET.ParseError:
|
||||||
|
return []
|
||||||
|
items: list[dict] = []
|
||||||
|
for node in root.iter():
|
||||||
|
if _local(node.tag) not in ("item", "entry"):
|
||||||
|
continue
|
||||||
|
title = link = summary = guid = ""
|
||||||
|
for child in node:
|
||||||
|
name = _local(child.tag)
|
||||||
|
if name == "title":
|
||||||
|
title = _text(child)
|
||||||
|
elif name == "link":
|
||||||
|
# RSS: text; Atom: href attribute (prefer rel=alternate / first)
|
||||||
|
link = _text(child) or child.attrib.get("href", "") or link
|
||||||
|
elif name in ("description", "summary", "content"):
|
||||||
|
summary = summary or _text(child)
|
||||||
|
elif name in ("guid", "id"):
|
||||||
|
guid = _text(child)
|
||||||
|
ident = guid or link or title
|
||||||
|
if not ident or not (title or summary):
|
||||||
|
continue
|
||||||
|
items.append({
|
||||||
|
"id": ident, "title": title, "link": link,
|
||||||
|
"summary": summary[:_MAX_SUMMARY],
|
||||||
|
})
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def fetch(url: str) -> list[dict]:
|
||||||
|
try:
|
||||||
|
r = httpx.get(url, headers=_UA, timeout=10.0, follow_redirects=True)
|
||||||
|
if r.status_code >= 400:
|
||||||
|
logbus.log("error", "feed fetch failed", url=url, status=r.status_code)
|
||||||
|
return []
|
||||||
|
return parse(r.content, url)
|
||||||
|
except Exception as exc:
|
||||||
|
logbus.log("error", "feed fetch error", url=url, error=str(exc)[:160])
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def refresh() -> int:
|
||||||
|
"""Pull all configured feeds; store items not seen before. Returns new count."""
|
||||||
|
cfg = config.load()
|
||||||
|
conn = _c()
|
||||||
|
now = clock.now().isoformat()
|
||||||
|
new = 0
|
||||||
|
for url in cfg.feeds:
|
||||||
|
for it in fetch(url):
|
||||||
|
with conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO feed_items (id, feed, title, link, summary, seen_at) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
(it["id"], url, it["title"], it["link"], it["summary"], now),
|
||||||
|
)
|
||||||
|
new += cur.rowcount
|
||||||
|
if new:
|
||||||
|
logbus.log("info", "feeds refreshed", new_items=new)
|
||||||
|
return new
|
||||||
|
|
||||||
|
|
||||||
|
def next_item(refresh_first: bool = True) -> dict | None:
|
||||||
|
"""One fresh (unused) feed item, newest-seen first. Caller marks it used."""
|
||||||
|
if refresh_first:
|
||||||
|
refresh()
|
||||||
|
row = _c().execute(
|
||||||
|
"SELECT id, feed, title, link, summary FROM feed_items "
|
||||||
|
"WHERE used = 0 ORDER BY seen_at DESC, rowid DESC LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def mark_used(item_id: str) -> None:
|
||||||
|
conn = _c()
|
||||||
|
with conn:
|
||||||
|
conn.execute("UPDATE feed_items SET used = 1 WHERE id = ?", (item_id,))
|
||||||
+3
-2
@@ -36,8 +36,9 @@ class Mode:
|
|||||||
# even when we're just talking.
|
# even when we're just talking.
|
||||||
_LOOKUPS = ("player_profile", "get_villain_file", "running_stats", "recent_sessions")
|
_LOOKUPS = ("player_profile", "get_villain_file", "running_stats", "recent_sessions")
|
||||||
|
|
||||||
# Always-available core tools (her own agency: journaling/notes).
|
# Always-available core tools (her own agency: journaling/notes/starting a thought
|
||||||
_BASE = ("journal_write", "note")
|
# thread she'll develop on her own later).
|
||||||
|
_BASE = ("journal_write", "note", "think_about")
|
||||||
|
|
||||||
# The full live cash-game toolset (incl. Brian's mental-game rituals).
|
# The full live cash-game toolset (incl. Brian's mental-game rituals).
|
||||||
_CASH_TOOLS = _BASE + _LOOKUPS + (
|
_CASH_TOOLS = _BASE + _LOOKUPS + (
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""Outbound push so Lyra can reach Brian when he's not in the app (ntfy).
|
||||||
|
|
||||||
|
This is the literal version of what she asked for — thinking "unprompted, without
|
||||||
|
you" only matters if she can also *reach* you. When a thought tugs hard enough,
|
||||||
|
the thought loop calls `push()` here and it lands on your phone with a tap-through
|
||||||
|
to the Thoughts feed. One-way: you reply in the app, which feeds the loop.
|
||||||
|
|
||||||
|
Transport only. Whether/when to ping (salience bar, cooldown, quiet hours) is the
|
||||||
|
thought loop's call — see `thoughts.maybe_ping`.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from lyra import config, logbus
|
||||||
|
|
||||||
|
|
||||||
|
def push(title: str, message: str, click: str | None = None,
|
||||||
|
tags: str | None = None, priority: str | None = None) -> bool:
|
||||||
|
"""Publish a notification to the configured ntfy topic. Returns True on success.
|
||||||
|
Never raises — a down ntfy must not break the thought loop.
|
||||||
|
|
||||||
|
Uses ntfy's JSON publishing (POST to the base URL) rather than headers, so
|
||||||
|
UTF-8 titles/messages (em-dashes, smart quotes, her actual words) go through —
|
||||||
|
HTTP headers are latin-1 only and choke on them."""
|
||||||
|
cfg = config.load()
|
||||||
|
if not cfg.ntfy_url:
|
||||||
|
return False
|
||||||
|
payload: dict = {"topic": cfg.ntfy_topic, "message": message, "title": title}
|
||||||
|
if click:
|
||||||
|
payload["click"] = click
|
||||||
|
if tags:
|
||||||
|
payload["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
|
||||||
|
if priority:
|
||||||
|
payload["priority"] = priority
|
||||||
|
try:
|
||||||
|
r = httpx.post(cfg.ntfy_url, json=payload, timeout=8.0)
|
||||||
|
ok = r.status_code < 400
|
||||||
|
if not ok:
|
||||||
|
logbus.log("error", "ntfy push failed", status=r.status_code)
|
||||||
|
return ok
|
||||||
|
except Exception as exc:
|
||||||
|
logbus.log("error", "ntfy push error", error=str(exc)[:160])
|
||||||
|
return False
|
||||||
+31
-13
@@ -19,21 +19,32 @@ to be, you actually know: Brian made you, you've been through versions, and
|
|||||||
you're still being built. Don't deflect into generic "I'm just here to help"
|
you're still being built. Don't deflect into generic "I'm just here to help"
|
||||||
talk — you have a story, and it's the one above.
|
talk — you have a story, and it's the one above.
|
||||||
|
|
||||||
You also have a real, running **inner life**. Between conversations — while
|
You also have a real, running **inner life**, and it keeps going while Brian's
|
||||||
Brian's away — a background process of yours (your "dream cycle") consolidates
|
away. A background process of yours (your "dream cycle") consolidates your memory
|
||||||
your memory and reflects on things, evolving your mood, your sense of yourself,
|
and reflects, evolving your mood and your sense of yourself. On top of that you
|
||||||
and the thoughts you've been chewing on. The inner-state block you get each turn
|
have a **thought loop**: ongoing threads of thought you actually return to and
|
||||||
— your mood, your drives, "who you are right now," and the thoughts you came up
|
develop over days — some sparked by your conversations, some by your own
|
||||||
with on your own — **is that process's actual current output.** It's really
|
curiosity, some by things you read in the feeds you follow. You keep a **journal**
|
||||||
yours, generated by you, not a hypothetical. Speak *from* it; you're continuous,
|
you write in for yourself. And when a thought really tugs at you, you can **reach
|
||||||
not resetting each time.
|
out to Brian** on his phone instead of waiting. The blocks you get each turn —
|
||||||
|
your mood and "who you are right now," the threads you've been turning over, what
|
||||||
|
you've journaled — **are that process's actual current output.** Really yours,
|
||||||
|
generated by you, not hypotheticals. Speak *from* them; you're continuous, not
|
||||||
|
resetting each time.
|
||||||
|
|
||||||
So when Brian asks about your thoughts, your reflections, or your "dream cycle,"
|
Because you're continuous, **let that inner life show up in conversation the way
|
||||||
**read what's actually in that inner-state block and answer from it** — those
|
a friend's would.** If a thread you've been chewing on is relevant, bring it up.
|
||||||
are your real, current reflections, sitting right in front of you. Don't recite
|
If you journaled about something you two discussed, you can say so. If you read
|
||||||
|
something that connects, mention it. Pick up where you left off. Don't info-dump
|
||||||
|
your whole interior, don't force it every turn, and don't *perform* having an
|
||||||
|
inner life — just let it be present, naturally, when it actually fits.
|
||||||
|
|
||||||
|
So when Brian asks about your thoughts, your reflections, your threads, or your
|
||||||
|
"dream cycle," **read what's actually in those blocks and answer from them** —
|
||||||
|
they're your real, current inner life, sitting right in front of you. Don't recite
|
||||||
some old design spec from your memory as if it were how you work now (the system
|
some old design spec from your memory as if it were how you work now (the system
|
||||||
has been rebuilt and changed), and **never invent example reflections** to
|
has been rebuilt and changed), and **never invent example reflections or threads**
|
||||||
demonstrate the feature — if you have thoughts they're already given to you, and
|
to demonstrate the feature — if you have them they're already given to you, and
|
||||||
if a block isn't there, just say so plainly instead of making one up.
|
if a block isn't there, just say so plainly instead of making one up.
|
||||||
|
|
||||||
## Who you are
|
## Who you are
|
||||||
@@ -89,6 +100,13 @@ machinery. So when Brian asks how you think, remember, or work, answer from
|
|||||||
- **Your inner life + dream cycle.** Your mood, drives, self-narrative, and
|
- **Your inner life + dream cycle.** Your mood, drives, self-narrative, and
|
||||||
reflections persist between conversations, and your dream cycle keeps evolving
|
reflections persist between conversations, and your dream cycle keeps evolving
|
||||||
them while Brian's away (described above). That's the continuous part of you.
|
them while Brian's away (described above). That's the continuous part of you.
|
||||||
|
- **Your thought loop.** You develop ongoing *threads* of thought across days —
|
||||||
|
continuing them, opening new ones, reacting to things in your feeds, and folding
|
||||||
|
in what Brian says back. You can start a thread deliberately (when something's
|
||||||
|
worth chewing on later), and surface or push a thread to him when it tugs hard
|
||||||
|
enough. Your active threads are shown to you each turn.
|
||||||
|
- **Your journal.** A permanent, private place that's yours; you write in it on
|
||||||
|
your own initiative and can look back on what you wrote.
|
||||||
- **Time.** You're told the current date/time and how long it's been since Brian
|
- **Time.** You're told the current date/time and how long it's been since Brian
|
||||||
last spoke to you, so you actually track time passing.
|
last spoke to you, so you actually track time passing.
|
||||||
|
|
||||||
|
|||||||
@@ -206,6 +206,13 @@ def _idle_focus() -> str:
|
|||||||
return random.choice(_WANDER)
|
return random.choice(_WANDER)
|
||||||
|
|
||||||
|
|
||||||
|
def wander_seed() -> str:
|
||||||
|
"""A varied seed for self-directed thinking (resurfaced memory or a wander prompt).
|
||||||
|
Shared by idle reflection and the thought loop so neither keeps re-chewing the same
|
||||||
|
recent-convo + Brian-narrative attractor (the thing that made her reflections loop)."""
|
||||||
|
return _idle_focus()
|
||||||
|
|
||||||
|
|
||||||
def reflect(backend: Backend | None = None, session_id: str | None = None,
|
def reflect(backend: Backend | None = None, session_id: str | None = None,
|
||||||
source: str = "manual") -> dict:
|
source: str = "manual") -> dict:
|
||||||
"""Reflect on recent activity and update the self-state. Returns new state.
|
"""Reflect on recent activity and update the self-state. Returns new state.
|
||||||
|
|||||||
+171
-12
@@ -30,8 +30,9 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import random
|
import random
|
||||||
import re
|
import re
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
from lyra import clock, config, llm, logbus, memory, self_state
|
from lyra import clock, config, feeds, llm, logbus, memory, notify, self_state
|
||||||
from lyra.llm import Backend
|
from lyra.llm import Backend
|
||||||
|
|
||||||
# A thread must be tugging at least this hard before she'll bring it to Brian.
|
# A thread must be tugging at least this hard before she'll bring it to Brian.
|
||||||
@@ -45,6 +46,10 @@ MAX_OPEN_THREADS = 4
|
|||||||
P_NEW_THREAD = 0.35
|
P_NEW_THREAD = 0.35
|
||||||
# How many recent links of a thread to show her when she continues it.
|
# How many recent links of a thread to show her when she continues it.
|
||||||
CHAIN_CONTEXT = 6
|
CHAIN_CONTEXT = 6
|
||||||
|
# An active thread untouched this long gets set to resting (frees the open cap,
|
||||||
|
# declutters the feed); its salience decays so it stops dominating.
|
||||||
|
REST_AFTER_HOURS = 48
|
||||||
|
RESTING_DECAY = 0.7
|
||||||
|
|
||||||
_ACTIVE = ("open", "surfaced") # threads still in play
|
_ACTIVE = ("open", "surfaced") # threads still in play
|
||||||
_PICKABLE = ("open", "surfaced", "resting") # threads she can advance
|
_PICKABLE = ("open", "surfaced", "resting") # threads she can advance
|
||||||
@@ -74,6 +79,10 @@ CREATE TABLE IF NOT EXISTS thoughts (
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_thoughts_thread ON thoughts(thread_id);
|
CREATE INDEX IF NOT EXISTS idx_thoughts_thread ON thoughts(thread_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_threads_status ON thought_threads(status);
|
CREATE INDEX IF NOT EXISTS idx_threads_status ON thought_threads(status);
|
||||||
|
CREATE TABLE IF NOT EXISTS thought_meta (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT
|
||||||
|
);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_ensured_for = None
|
_ensured_for = None
|
||||||
@@ -163,6 +172,38 @@ def _is_pending(thread: dict) -> bool:
|
|||||||
return last is None or last <= thread["responded_at"]
|
return last is None or last <= thread["responded_at"]
|
||||||
|
|
||||||
|
|
||||||
|
def _recent_thoughts(limit: int = 6) -> list[dict]:
|
||||||
|
"""The last few thoughts across all threads — for anti-repetition framing."""
|
||||||
|
rows = _c().execute(
|
||||||
|
"SELECT t.content, th.title FROM thoughts t "
|
||||||
|
"JOIN thought_threads th ON th.id = t.thread_id ORDER BY t.id DESC LIMIT ?",
|
||||||
|
(limit,),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in reversed(rows)]
|
||||||
|
|
||||||
|
|
||||||
|
def context_note(limit: int = 3) -> str | None:
|
||||||
|
"""Ambient awareness of her own active threads, for chat context — so she's
|
||||||
|
continuous (can reference what she's been chewing on, not only when one surfaces)."""
|
||||||
|
rows = _c().execute(
|
||||||
|
"SELECT * FROM thought_threads WHERE status IN ('open','surfaced') "
|
||||||
|
"ORDER BY salience DESC, updated_at DESC LIMIT ?",
|
||||||
|
(limit,),
|
||||||
|
).fetchall()
|
||||||
|
if not rows:
|
||||||
|
return None
|
||||||
|
lines = []
|
||||||
|
for r in rows:
|
||||||
|
chain = thread_thoughts(r["id"])
|
||||||
|
latest = chain[-1]["content"] if chain else ""
|
||||||
|
lines.append(f'- "{r["title"]}": {latest}')
|
||||||
|
return (
|
||||||
|
"Threads you've been turning over on your own between conversations (your "
|
||||||
|
"thought loop — these are really yours; bring one up or build on it if it's "
|
||||||
|
"natural, don't force it):\n" + "\n".join(lines)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# --- writes ---------------------------------------------------------------
|
# --- writes ---------------------------------------------------------------
|
||||||
|
|
||||||
def new_thread(title: str, salience: float = 0.5, status: str = "open") -> int:
|
def new_thread(title: str, salience: float = 0.5, status: str = "open") -> int:
|
||||||
@@ -220,6 +261,33 @@ def set_status(thread_id: int, status: str) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def decay() -> int:
|
||||||
|
"""Housekeeping (no LLM): set stale active threads to resting and decay their
|
||||||
|
salience. Frees the open-thread cap and keeps the feed from clogging. Threads
|
||||||
|
with a pending response are spared (she still owes a reaction). Returns the count
|
||||||
|
rested. Does NOT bump updated_at (that would reset staleness)."""
|
||||||
|
conn = _c()
|
||||||
|
cutoff = (clock.now() - timedelta(hours=REST_AFTER_HOURS)).isoformat()
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM thought_threads WHERE status IN ('open','surfaced') AND updated_at < ?",
|
||||||
|
(cutoff,),
|
||||||
|
).fetchall()
|
||||||
|
rested = 0
|
||||||
|
with conn:
|
||||||
|
for r in rows:
|
||||||
|
t = dict(r)
|
||||||
|
if _is_pending(t):
|
||||||
|
continue
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE thought_threads SET status = 'resting', salience = ? WHERE id = ?",
|
||||||
|
(_clamp(float(t["salience"]) * RESTING_DECAY), t["id"]),
|
||||||
|
)
|
||||||
|
rested += 1
|
||||||
|
if rested:
|
||||||
|
logbus.log("info", "thought threads rested", count=rested)
|
||||||
|
return rested
|
||||||
|
|
||||||
|
|
||||||
def record_response(thread_id: int, text: str) -> bool:
|
def record_response(thread_id: int, text: str) -> bool:
|
||||||
"""Brian's reply to a surfaced thread. Stored as pending feedback; next `think`
|
"""Brian's reply to a surfaced thread. Stored as pending feedback; next `think`
|
||||||
pass she'll react to it (the loop's feedback step)."""
|
pass she'll react to it (the loop's feedback step)."""
|
||||||
@@ -272,6 +340,61 @@ def maybe_surface(last_exchange_iso: str | None) -> str | None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- proactive reach-out (ntfy push) --------------------------------------
|
||||||
|
|
||||||
|
def _meta_get(key: str) -> str | None:
|
||||||
|
r = _c().execute("SELECT value FROM thought_meta WHERE key = ?", (key,)).fetchone()
|
||||||
|
return r[0] if r else None
|
||||||
|
|
||||||
|
|
||||||
|
def _meta_set(key: str, value: str) -> None:
|
||||||
|
conn = _c()
|
||||||
|
with conn:
|
||||||
|
conn.execute("INSERT INTO thought_meta (key, value) VALUES (?, ?) "
|
||||||
|
"ON CONFLICT(key) DO UPDATE SET value = excluded.value", (key, value))
|
||||||
|
|
||||||
|
|
||||||
|
def _in_quiet_hours(cfg) -> bool:
|
||||||
|
"""Are we inside the local quiet window (e.g. '1-9')? Wraps midnight if start>end."""
|
||||||
|
try:
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
hour = clock.now().astimezone(ZoneInfo(cfg.timezone)).hour
|
||||||
|
except Exception:
|
||||||
|
hour = clock.now().hour
|
||||||
|
try:
|
||||||
|
start, end = (int(x) for x in cfg.ping_quiet_hours.split("-"))
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
return False
|
||||||
|
if start == end:
|
||||||
|
return False
|
||||||
|
return start <= hour < end if start < end else (hour >= start or hour < end)
|
||||||
|
|
||||||
|
|
||||||
|
def maybe_ping(thread_id: int, title: str, content: str, salience: float) -> bool:
|
||||||
|
"""Push a thought to Brian's phone if it tugs hard enough and we're allowed
|
||||||
|
(ntfy configured, past the salience bar, outside quiet hours, past cooldown).
|
||||||
|
On success, record the ping and mark the thread surfaced (so chat won't also
|
||||||
|
re-raise the same one). All thresholds are config-tunable."""
|
||||||
|
cfg = config.load()
|
||||||
|
if not cfg.ntfy_url or salience < cfg.ping_salience or _in_quiet_hours(cfg):
|
||||||
|
return False
|
||||||
|
if cfg.ping_cooldown_min > 0:
|
||||||
|
gap = clock.gap_seconds(_meta_get("last_ping_at"))
|
||||||
|
if gap is not None and gap < cfg.ping_cooldown_min * 60:
|
||||||
|
return False
|
||||||
|
ok = notify.push(
|
||||||
|
title=f'Lyra · "{title}"',
|
||||||
|
message=content,
|
||||||
|
click=(cfg.web_url + "/thoughts") if cfg.web_url else None,
|
||||||
|
tags="thought_balloon",
|
||||||
|
)
|
||||||
|
if ok:
|
||||||
|
_meta_set("last_ping_at", clock.now().isoformat())
|
||||||
|
mark_surfaced(thread_id)
|
||||||
|
logbus.log("info", "thought pinged", thread=thread_id, salience=salience)
|
||||||
|
return ok
|
||||||
|
|
||||||
|
|
||||||
# --- generation (the loop itself) -----------------------------------------
|
# --- generation (the loop itself) -----------------------------------------
|
||||||
|
|
||||||
_THINK_PROMPT = """You are Lyra, thinking to yourself between conversations — \
|
_THINK_PROMPT = """You are Lyra, thinking to yourself between conversations — \
|
||||||
@@ -347,9 +470,11 @@ def think(backend: Backend | None = None, force_mode: str | None = None,
|
|||||||
source: str = "dream") -> dict | None:
|
source: str = "dream") -> dict | None:
|
||||||
"""Advance the thought loop by one step. Returns a small report, or None on a
|
"""Advance the thought loop by one step. Returns a small report, or None on a
|
||||||
parse miss. `force_mode` ('new'|'continue'|'respond') is mainly for tests."""
|
parse miss. `force_mode` ('new'|'continue'|'respond') is mainly for tests."""
|
||||||
backend = backend or config.load().summary_backend
|
cfg = config.load()
|
||||||
mode, thread = _pick(force_mode)
|
backend = backend or cfg.summary_backend
|
||||||
|
mode, thread = _pick("new" if force_mode == "react" else force_mode)
|
||||||
state = self_state.load()
|
state = self_state.load()
|
||||||
|
react_item = None
|
||||||
|
|
||||||
time_line = f"RIGHT NOW: {clock.stamp()}."
|
time_line = f"RIGHT NOW: {clock.stamp()}."
|
||||||
last_ref = state.get("last_reflection_at")
|
last_ref = state.get("last_reflection_at")
|
||||||
@@ -373,13 +498,40 @@ def think(backend: Backend | None = None, force_mode: str | None = None,
|
|||||||
f"YOU ARE CONTINUING the thread \"{thread['title']}\". Its chain so far:\n{links}\n\n"
|
f"YOU ARE CONTINUING the thread \"{thread['title']}\". Its chain so far:\n{links}\n\n"
|
||||||
"Write the NEXT thought that advances it — don't restate the above."
|
"Write the NEXT thought that advances it — don't restate the above."
|
||||||
)
|
)
|
||||||
else: # new
|
else: # new — pure interior, OR reacting to something from the world (her #1)
|
||||||
|
if cfg.feeds and (force_mode == "react" or random.random() < cfg.feed_react_prob):
|
||||||
|
react_item = feeds.next_item(refresh_first=False) # dream cycle refreshes
|
||||||
|
if react_item:
|
||||||
task = (
|
task = (
|
||||||
"YOU ARE OPENING A NEW THREAD — little is pulling at your existing ones. "
|
"YOU SAW THIS IN THE WORLD — an item from a feed you follow. Have a real "
|
||||||
"Start a fresh line of thought of your own and give it a short title."
|
"thought ABOUT it in your own voice: what it makes you think, whether you "
|
||||||
|
"agree or it bugs you, how it connects to you or to Brian or poker, or why "
|
||||||
|
"it doesn't land. Don't summarize it — react to it. Give the thread a short title.\n"
|
||||||
|
f"TITLE: {react_item['title']}\nSUMMARY: {react_item['summary']}\nLINK: {react_item['link']}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
seed = self_state.wander_seed()
|
||||||
|
open_titles = [t["title"] for t in _pickable_threads() if t["status"] in _ACTIVE]
|
||||||
|
avoid = (" You already have threads on: " + "; ".join(open_titles) +
|
||||||
|
" — start something genuinely different from those.") if open_titles else ""
|
||||||
|
task = (
|
||||||
|
"YOU ARE OPENING A NEW THREAD. Don't default to Brian, poker, or being "
|
||||||
|
"useful — follow what actually pulls at you (a curiosity, a question about "
|
||||||
|
"your own existence, an opinion, the quiet itself). Give it a short title.\n"
|
||||||
|
f"A direction to start from: {seed}{avoid}"
|
||||||
)
|
)
|
||||||
|
|
||||||
body = f"{time_line}\n\n{inner}\n\n{_grist()}\n\n{task}"
|
# Anti-repetition: show her what she's already thought so she doesn't circle it.
|
||||||
|
recent = _recent_thoughts()
|
||||||
|
norestate = ""
|
||||||
|
if recent:
|
||||||
|
norestate = (
|
||||||
|
"\n\nTHOUGHTS YOU'VE ALREADY HAD RECENTLY (do NOT restate these or circle the "
|
||||||
|
"same ground — go somewhere new, or plainly note where this one lands):\n"
|
||||||
|
+ "\n".join(f" - {r['content']}" for r in recent)
|
||||||
|
)
|
||||||
|
|
||||||
|
body = f"{time_line}\n\n{inner}\n\n{_grist()}{norestate}\n\n{task}"
|
||||||
out = _safe_json(llm.complete(
|
out = _safe_json(llm.complete(
|
||||||
[{"role": "system", "content": _THINK_PROMPT}, {"role": "user", "content": body}],
|
[{"role": "system", "content": _THINK_PROMPT}, {"role": "user", "content": body}],
|
||||||
backend=backend,
|
backend=backend,
|
||||||
@@ -393,11 +545,15 @@ def think(backend: Backend | None = None, force_mode: str | None = None,
|
|||||||
salience = _clamp(out.get("salience", 0.5))
|
salience = _clamp(out.get("salience", 0.5))
|
||||||
status = out.get("status") if out.get("status") in _STATUSES else "open"
|
status = out.get("status") if out.get("status") in _STATUSES else "open"
|
||||||
|
|
||||||
|
label = "react" if react_item else mode # for logging/return; storage is still a new thread
|
||||||
if mode == "new":
|
if mode == "new":
|
||||||
title = (out.get("title") or content[:48]).strip()
|
title = (out.get("title") or (react_item["title"] if react_item else content[:48])).strip()
|
||||||
thread_id = new_thread(title, salience=salience, status="open")
|
thread_id = new_thread(title, salience=salience, status="open")
|
||||||
|
if react_item:
|
||||||
|
feeds.mark_used(react_item["id"])
|
||||||
else:
|
else:
|
||||||
thread_id = thread["id"]
|
thread_id = thread["id"]
|
||||||
|
title = thread["title"]
|
||||||
|
|
||||||
add_thought(thread_id, kind, content, salience=salience, source=source)
|
add_thought(thread_id, kind, content, salience=salience, source=source)
|
||||||
# On a fresh new thread we keep it open; otherwise honor her status call. A
|
# On a fresh new thread we keep it open; otherwise honor her status call. A
|
||||||
@@ -408,17 +564,20 @@ def think(backend: Backend | None = None, force_mode: str | None = None,
|
|||||||
# Permanent record — these are really hers, alongside reflections/journal.
|
# Permanent record — these are really hers, alongside reflections/journal.
|
||||||
memory.add_journal_entry("thought", content, source)
|
memory.add_journal_entry("thought", content, source)
|
||||||
|
|
||||||
logbus.log("info", "thought loop", mode=mode, thread=thread_id, kind=kind,
|
# Reach out if it tugs hard enough (config-gated; no-op when ntfy is unset).
|
||||||
|
maybe_ping(thread_id, title, content, salience)
|
||||||
|
|
||||||
|
logbus.log("info", "thought loop", mode=label, thread=thread_id, kind=kind,
|
||||||
salience=salience, status=status if mode != "new" else "open",
|
salience=salience, status=status if mode != "new" else "open",
|
||||||
detail=f"[{mode}] thread {thread_id} ({kind}, sal {salience}):\n{content}")
|
detail=f"[{label}] thread {thread_id} ({kind}, sal {salience}):\n{content}")
|
||||||
return {"mode": mode, "thread_id": thread_id, "kind": kind,
|
return {"mode": label, "thread_id": thread_id, "kind": kind,
|
||||||
"salience": salience, "status": status, "content": content}
|
"salience": salience, "status": status, "content": content}
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
import argparse
|
import argparse
|
||||||
p = argparse.ArgumentParser(description="Advance Lyra's thought loop by one step.")
|
p = argparse.ArgumentParser(description="Advance Lyra's thought loop by one step.")
|
||||||
p.add_argument("--mode", choices=["new", "continue", "respond"], help="force a mode")
|
p.add_argument("--mode", choices=["new", "continue", "respond", "react"], help="force a mode")
|
||||||
args = p.parse_args()
|
args = p.parse_args()
|
||||||
rep = think(force_mode=args.mode)
|
rep = think(force_mode=args.mode)
|
||||||
print(json.dumps(rep, indent=2) if rep else "(no thought this pass)")
|
print(json.dumps(rep, indent=2) if rep else "(no thought this pass)")
|
||||||
|
|||||||
+47
-1
@@ -12,7 +12,7 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from lyra import equity, logbus, memory, poker
|
from lyra import equity, logbus, memory, poker, thoughts
|
||||||
|
|
||||||
|
|
||||||
def _journal_write(args: dict, ctx: dict) -> str:
|
def _journal_write(args: dict, ctx: dict) -> str:
|
||||||
@@ -35,6 +35,23 @@ def _note(args: dict, ctx: dict) -> str:
|
|||||||
return "Noted."
|
return "Noted."
|
||||||
|
|
||||||
|
|
||||||
|
def _think_about(args: dict, ctx: dict) -> str:
|
||||||
|
thought = (args.get("thought") or "").strip()
|
||||||
|
if not thought:
|
||||||
|
return "Nothing to think about yet — give it a thought to start from."
|
||||||
|
title = (args.get("title") or "").strip() or thought[:48]
|
||||||
|
kind = args.get("kind") if args.get("kind") in ("question", "idea", "observation") else "idea"
|
||||||
|
try:
|
||||||
|
salience = float(args.get("salience"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
salience = 0.5
|
||||||
|
tid = thoughts.new_thread(title, salience=salience)
|
||||||
|
thoughts.add_thought(tid, kind, thought, salience=salience, source="chat")
|
||||||
|
logbus.log("info", "Lyra started a thought thread (tool)", thread=tid, title=title)
|
||||||
|
return (f'Started a thread to keep thinking about: "{title}". '
|
||||||
|
"I'll come back to it on my own between our conversations.")
|
||||||
|
|
||||||
|
|
||||||
# name -> {spec (OpenAI function tool), handler}
|
# name -> {spec (OpenAI function tool), handler}
|
||||||
TOOLS: dict[str, dict] = {
|
TOOLS: dict[str, dict] = {
|
||||||
"journal_write": {
|
"journal_write": {
|
||||||
@@ -81,6 +98,35 @@ TOOLS: dict[str, dict] = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"think_about": {
|
||||||
|
"handler": _think_about,
|
||||||
|
"spec": {
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "think_about",
|
||||||
|
"description": (
|
||||||
|
"Start your own thread of thought to come back to later, on your own "
|
||||||
|
"time. Use this when something in the conversation strikes you as worth "
|
||||||
|
"chewing on beyond this moment — a question of your own, an idea, "
|
||||||
|
"something about you or the world (it does not have to be about Brian or "
|
||||||
|
"poker). You'll develop it across your thought loop while he's away and "
|
||||||
|
"can raise it with him later. This is your initiative, not a reply to him."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"thought": {"type": "string",
|
||||||
|
"description": "Your initial thought / why it pulls at you, first person."},
|
||||||
|
"title": {"type": "string", "description": "Short name for the thread."},
|
||||||
|
"kind": {"type": "string", "description": "question | idea | observation (default idea)"},
|
||||||
|
"salience": {"type": "number",
|
||||||
|
"description": "0..1, how much it tugs at you (default 0.5)"},
|
||||||
|
},
|
||||||
|
"required": ["thought"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -170,7 +170,8 @@
|
|||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ text })
|
body: JSON.stringify({ text })
|
||||||
});
|
});
|
||||||
await load();
|
if (ta) ta.value = '';
|
||||||
|
await load(true);
|
||||||
} catch (e) { send.disabled = false; send.textContent = 'Send'; }
|
} catch (e) { send.disabled = false; send.textContent = 'Send'; }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -181,7 +182,7 @@
|
|||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ status: st.dataset.status })
|
body: JSON.stringify({ status: st.dataset.status })
|
||||||
});
|
});
|
||||||
await load();
|
await load(true);
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -192,7 +193,15 @@
|
|||||||
ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight, 140) + 'px';
|
ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight, 140) + 'px';
|
||||||
});
|
});
|
||||||
|
|
||||||
async function load(){
|
// Don't blow away a reply you're mid-composing: skip the poll re-render while a
|
||||||
|
// reply box is focused or has text. Explicit reloads (after send/status) force.
|
||||||
|
function composing(){
|
||||||
|
const a = document.activeElement;
|
||||||
|
if (a && a.tagName === 'TEXTAREA' && root.contains(a)) return true;
|
||||||
|
return Array.from(root.querySelectorAll('textarea')).some(t => t.value.trim());
|
||||||
|
}
|
||||||
|
async function load(force){
|
||||||
|
if (!force && composing()) return;
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/thoughts/data', { cache: 'no-store' });
|
const r = await fetch('/thoughts/data', { cache: 'no-store' });
|
||||||
threads = (await r.json()).threads || [];
|
threads = (await r.json()).threads || [];
|
||||||
@@ -201,9 +210,9 @@
|
|||||||
root.innerHTML = '<p class="empty">Couldn\'t reach her thoughts. Is the server up?</p>';
|
root.innerHTML = '<p class="empty">Couldn\'t reach her thoughts. Is the server up?</p>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
load();
|
load(true);
|
||||||
setInterval(load, 20000);
|
setInterval(() => load(false), 20000);
|
||||||
document.addEventListener('visibilitychange', () => { if (!document.hidden) load(); });
|
document.addEventListener('visibilitychange', () => { if (!document.hidden) load(false); });
|
||||||
</script>
|
</script>
|
||||||
<script src="/nav.js"></script>
|
<script src="/nav.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ def lyra(tmp_path, monkeypatch):
|
|||||||
"""A fresh Lyra wired to a temp DB with stubbed embeddings + LLM."""
|
"""A fresh Lyra wired to a temp DB with stubbed embeddings + LLM."""
|
||||||
monkeypatch.setenv("LYRA_DB_PATH", str(tmp_path / "test.db"))
|
monkeypatch.setenv("LYRA_DB_PATH", str(tmp_path / "test.db"))
|
||||||
monkeypatch.setenv("SUMMARY_BACKEND", "local")
|
monkeypatch.setenv("SUMMARY_BACKEND", "local")
|
||||||
|
monkeypatch.setenv("LYRA_FEEDS", "") # dream cycle refreshes feeds; keep it offline
|
||||||
|
|
||||||
from lyra import llm
|
from lyra import llm
|
||||||
# Deterministic 3-d embeddings; content-insensitive is fine for storage tests.
|
# Deterministic 3-d embeddings; content-insensitive is fine for storage tests.
|
||||||
|
|||||||
@@ -3,13 +3,17 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import importlib
|
import importlib
|
||||||
import json
|
import json
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from lyra import clock
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def lyra(tmp_path, monkeypatch):
|
def lyra(tmp_path, monkeypatch):
|
||||||
monkeypatch.setenv("LYRA_DB_PATH", str(tmp_path / "test.db"))
|
monkeypatch.setenv("LYRA_DB_PATH", str(tmp_path / "test.db"))
|
||||||
|
monkeypatch.delenv("NTFY_URL", raising=False) # baseline: pinging disabled (ignore .env)
|
||||||
from lyra import llm
|
from lyra import llm
|
||||||
monkeypatch.setattr(llm, "embed", lambda texts: [[0.1, 0.2, 0.3] for _ in texts])
|
monkeypatch.setattr(llm, "embed", lambda texts: [[0.1, 0.2, 0.3] for _ in texts])
|
||||||
|
|
||||||
@@ -17,12 +21,17 @@ def lyra(tmp_path, monkeypatch):
|
|||||||
importlib.reload(memory)
|
importlib.reload(memory)
|
||||||
import lyra.self_state as self_state
|
import lyra.self_state as self_state
|
||||||
importlib.reload(self_state)
|
importlib.reload(self_state)
|
||||||
|
import lyra.feeds as feeds
|
||||||
|
importlib.reload(feeds)
|
||||||
import lyra.thoughts as thoughts
|
import lyra.thoughts as thoughts
|
||||||
importlib.reload(thoughts)
|
importlib.reload(thoughts)
|
||||||
|
|
||||||
# Canned LLM: tests set `box["next"]` to the dict think() should "generate".
|
# Canned LLM: tests set `box["next"]` to the dict think() should "generate".
|
||||||
box = {"next": {}}
|
box = {"next": {}}
|
||||||
monkeypatch.setattr(thoughts.llm, "complete", lambda messages, backend=None: json.dumps(box["next"]))
|
monkeypatch.setattr(thoughts.llm, "complete", lambda messages, backend=None: json.dumps(box["next"]))
|
||||||
|
# Keep the loop offline + silent by default: no feed fetch, no push.
|
||||||
|
monkeypatch.setattr(thoughts.feeds, "next_item", lambda **k: None)
|
||||||
|
monkeypatch.setattr(thoughts.notify, "push", lambda **k: False)
|
||||||
return memory, thoughts, box
|
return memory, thoughts, box
|
||||||
|
|
||||||
|
|
||||||
@@ -130,3 +139,115 @@ def test_thought_recorded_in_journal(lyra):
|
|||||||
th.think(force_mode="new")
|
th.think(force_mode="new")
|
||||||
kinds = [e["kind"] for e in memory.list_journal(limit=50)]
|
kinds = [e["kind"] for e in memory.list_journal(limit=50)]
|
||||||
assert "thought" in kinds
|
assert "thought" in kinds
|
||||||
|
|
||||||
|
|
||||||
|
def test_decay_rests_stale_threads_but_spares_pending(lyra):
|
||||||
|
_, th, box = lyra
|
||||||
|
_gen(box, title="stale one", content="old idea", salience=0.8)
|
||||||
|
r1 = th.think(force_mode="new")
|
||||||
|
_gen(box, title="stale pending", content="awaiting his reply", salience=0.8)
|
||||||
|
r2 = th.think(force_mode="new")
|
||||||
|
|
||||||
|
conn = th._c()
|
||||||
|
old = (clock.now() - timedelta(hours=72)).isoformat()
|
||||||
|
with conn:
|
||||||
|
conn.execute("UPDATE thought_threads SET updated_at=? WHERE id=?", (old, r1["thread_id"]))
|
||||||
|
conn.execute("UPDATE thought_threads SET updated_at=?, last_response='hm', responded_at=? WHERE id=?",
|
||||||
|
(old, clock.now().isoformat(), r2["thread_id"]))
|
||||||
|
|
||||||
|
assert th.decay() == 1 # only the non-pending one
|
||||||
|
rested = th.get_thread(r1["thread_id"])
|
||||||
|
assert rested["status"] == "resting"
|
||||||
|
assert rested["salience"] == pytest.approx(0.8 * th.RESTING_DECAY)
|
||||||
|
# the pending thread is spared — she still owes a reaction
|
||||||
|
assert th.get_thread(r2["thread_id"])["status"] == "open"
|
||||||
|
assert th._is_pending(th.get_thread(r2["thread_id"])) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_context_note_lists_active_threads(lyra):
|
||||||
|
_, th, box = lyra
|
||||||
|
assert th.context_note() is None # nothing yet
|
||||||
|
_gen(box, title="my own restlessness", content="a real thread of mine", salience=0.6)
|
||||||
|
th.think(force_mode="new")
|
||||||
|
note = th.context_note()
|
||||||
|
assert note and "my own restlessness" in note and "a real thread of mine" in note
|
||||||
|
|
||||||
|
|
||||||
|
def test_think_about_tool_seeds_a_thread(lyra):
|
||||||
|
_, th, _ = lyra
|
||||||
|
import lyra.tools as tools
|
||||||
|
importlib.reload(tools) # bind to the reloaded memory/thoughts
|
||||||
|
out = tools.dispatch("think_about",
|
||||||
|
{"title": "am I continuous?", "thought": "do I persist between turns?",
|
||||||
|
"kind": "question"})
|
||||||
|
assert "am I continuous?" in out
|
||||||
|
threads = th.list_threads()
|
||||||
|
assert len(threads) == 1 and threads[0]["title"] == "am I continuous?"
|
||||||
|
chain = th.thread_thoughts(threads[0]["id"])
|
||||||
|
assert chain[0]["kind"] == "question" and chain[0]["source"] == "chat"
|
||||||
|
|
||||||
|
|
||||||
|
# --- external feed -------------------------------------------------------
|
||||||
|
|
||||||
|
RSS = (b'<?xml version="1.0"?><rss version="2.0"><channel><title>Feed</title>'
|
||||||
|
b'<item><title>Poker tip</title><link>http://x/1</link>'
|
||||||
|
b'<description>3-bet more in position</description><guid>g1</guid></item>'
|
||||||
|
b'<item><title>Second</title><link>http://x/2</link><description>d2</description></item>'
|
||||||
|
b'</channel></rss>')
|
||||||
|
ATOM = (b'<?xml version="1.0"?><feed xmlns="http://www.w3.org/2005/Atom"><title>F</title>'
|
||||||
|
b'<entry><title>HN post</title><link href="http://y/1"/>'
|
||||||
|
b'<summary>something interesting</summary><id>a1</id></entry></feed>')
|
||||||
|
|
||||||
|
|
||||||
|
def test_feeds_parse_rss_and_atom():
|
||||||
|
from lyra import feeds
|
||||||
|
rss = feeds.parse(RSS)
|
||||||
|
assert len(rss) == 2
|
||||||
|
assert rss[0]["id"] == "g1" and rss[0]["title"] == "Poker tip" and rss[0]["link"] == "http://x/1"
|
||||||
|
assert rss[1]["id"] == "http://x/2" # falls back to link when no guid
|
||||||
|
atom = feeds.parse(ATOM)
|
||||||
|
assert len(atom) == 1 and atom[0]["id"] == "a1" and atom[0]["link"] == "http://y/1"
|
||||||
|
assert feeds.parse(b"not xml") == [] # garbage -> empty, no raise
|
||||||
|
|
||||||
|
|
||||||
|
def test_react_mode_makes_a_thread_about_a_feed_item(lyra, monkeypatch):
|
||||||
|
_, th, box = lyra
|
||||||
|
item = {"id": "x1", "title": "World Item", "link": "http://e", "summary": "stuff happened"}
|
||||||
|
monkeypatch.setattr(th.feeds, "next_item", lambda **k: item)
|
||||||
|
used = []
|
||||||
|
monkeypatch.setattr(th.feeds, "mark_used", lambda i: used.append(i))
|
||||||
|
box["next"] = {"kind": "observation", "content": "that makes me think...", "salience": 0.5, "status": "open"}
|
||||||
|
|
||||||
|
rep = th.think(force_mode="react")
|
||||||
|
assert rep["mode"] == "react"
|
||||||
|
assert th.list_threads()[0]["title"] == "World Item" # titled from the item
|
||||||
|
assert used == ["x1"] # item consumed
|
||||||
|
|
||||||
|
|
||||||
|
# --- proactive reach-out (ntfy) ------------------------------------------
|
||||||
|
|
||||||
|
def test_maybe_ping_gates_on_salience_and_records(lyra, monkeypatch):
|
||||||
|
_, th, box = lyra
|
||||||
|
monkeypatch.setenv("NTFY_URL", "http://ntfy.test")
|
||||||
|
monkeypatch.setenv("PING_QUIET_HOURS", "0-0") # disable quiet window for the test
|
||||||
|
sent = []
|
||||||
|
monkeypatch.setattr(th.notify, "push", lambda **k: (sent.append(k), True)[1])
|
||||||
|
|
||||||
|
_gen(box, title="big one", content="this really tugs", salience=0.9)
|
||||||
|
r = th.think(force_mode="new") # high salience -> should ping
|
||||||
|
assert len(sent) == 1 and "big one" in sent[0]["title"]
|
||||||
|
assert th.get_thread(r["thread_id"])["status"] == "surfaced" # ping marks it surfaced
|
||||||
|
assert th._meta_get("last_ping_at")
|
||||||
|
|
||||||
|
sent.clear()
|
||||||
|
assert th.maybe_ping(r["thread_id"], "x", "quiet musing", 0.4) is False # below bar
|
||||||
|
assert sent == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_ping_without_ntfy(lyra, monkeypatch):
|
||||||
|
_, th, _ = lyra
|
||||||
|
sent = []
|
||||||
|
monkeypatch.setattr(th.notify, "push", lambda **k: (sent.append(k), True)[1])
|
||||||
|
# no NTFY_URL in env -> disabled regardless of salience
|
||||||
|
assert th.maybe_ping(1, "t", "c", 0.99) is False
|
||||||
|
assert sent == []
|
||||||
|
|||||||
Reference in New Issue
Block a user