Compare commits
5 Commits
05ae98abdb
...
149e9a6dd5
| Author | SHA1 | Date | |
|---|---|---|---|
| 149e9a6dd5 | |||
| cf4238911e | |||
| 3dd9eb5a3e | |||
| a7966e4bab | |||
| a705e573a9 |
@@ -45,3 +45,6 @@ FEED_REACT_PROB=0.5 # chance a new thought reacts to a feed item
|
|||||||
# Defaults to SUMMARY_BACKEND. Set to run her reflections/thoughts on a steerable model.
|
# Defaults to SUMMARY_BACKEND. Set to run her reflections/thoughts on a steerable model.
|
||||||
INTROSPECTION_BACKEND=
|
INTROSPECTION_BACKEND=
|
||||||
INTROSPECTION_MODEL=
|
INTROSPECTION_MODEL=
|
||||||
|
PING_AUTO_SALIENCE=0.8 # a thought this salient auto-pings even without an explicit reach-out
|
||||||
|
PING_COOLDOWN_MIN=60 # min minutes between AUTO pings (explicit reach-outs bypass)
|
||||||
|
DIGEST_HOUR=18 # local hour to send her daily "what I've been thinking" digest
|
||||||
|
|||||||
+7
-3
@@ -32,9 +32,11 @@ class Config:
|
|||||||
ntfy_topic: str # topic to publish to, e.g. "lyra"
|
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
|
web_url: str # base url of the Lyra web app, for push tap-through links
|
||||||
timezone: str # IANA tz for quiet hours / local time
|
timezone: str # IANA tz for quiet hours / local time
|
||||||
ping_salience: float # min thought salience to push (eager = ~0.7)
|
ping_salience: float # hard floor for any push (0 = her decision drives it)
|
||||||
ping_cooldown_min: int # min minutes between pushes (eager = 0)
|
ping_auto_salience: float # a thought this salient auto-pings even without an explicit reach-out
|
||||||
|
ping_cooldown_min: int # min minutes between AUTO pushes (explicit reach-outs bypass it)
|
||||||
ping_quiet_hours: str # local "start-end" 24h window to stay silent, e.g. "1-9"
|
ping_quiet_hours: str # local "start-end" 24h window to stay silent, e.g. "1-9"
|
||||||
|
digest_hour: int # local hour (0-23) to send her daily "what I've been thinking" digest
|
||||||
# External input feed (her #1: react to the world). Comma-separated RSS/Atom URLs.
|
# External input feed (her #1: react to the world). Comma-separated RSS/Atom URLs.
|
||||||
feeds: tuple[str, ...]
|
feeds: tuple[str, ...]
|
||||||
feed_react_prob: float # chance a would-be new thread reacts to a feed item instead
|
feed_react_prob: float # chance a would-be new thread reacts to a feed item instead
|
||||||
@@ -73,8 +75,10 @@ def load() -> Config:
|
|||||||
web_url=os.getenv("LYRA_WEB_URL", "").rstrip("/"),
|
web_url=os.getenv("LYRA_WEB_URL", "").rstrip("/"),
|
||||||
timezone=os.getenv("LYRA_TIMEZONE", "America/New_York"),
|
timezone=os.getenv("LYRA_TIMEZONE", "America/New_York"),
|
||||||
ping_salience=float(os.getenv("PING_SALIENCE", "0.0")), # her decision drives pinging; optional floor
|
ping_salience=float(os.getenv("PING_SALIENCE", "0.0")), # her decision drives pinging; optional floor
|
||||||
ping_cooldown_min=int(os.getenv("PING_COOLDOWN_MIN", "0")),
|
ping_auto_salience=float(os.getenv("PING_AUTO_SALIENCE", "0.8")),
|
||||||
|
ping_cooldown_min=int(os.getenv("PING_COOLDOWN_MIN", "60")),
|
||||||
ping_quiet_hours=os.getenv("PING_QUIET_HOURS", "1-9"),
|
ping_quiet_hours=os.getenv("PING_QUIET_HOURS", "1-9"),
|
||||||
|
digest_hour=int(os.getenv("DIGEST_HOUR", "18")),
|
||||||
feeds=_csv("LYRA_FEEDS", "https://hnrss.org/frontpage,https://www.pokernews.com/rss.php"),
|
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")),
|
feed_react_prob=float(os.getenv("FEED_REACT_PROB", "0.5")),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -87,6 +87,11 @@ def dream_cycle(backend: Backend | None = None, force: bool = False) -> dict:
|
|||||||
feeds.refresh()
|
feeds.refresh()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logbus.log("error", "feed refresh failed", error=str(exc)[:160])
|
logbus.log("error", "feed refresh failed", error=str(exc)[:160])
|
||||||
|
# Her daily "what I've been turning over" digest (sends at most once/local-day).
|
||||||
|
try:
|
||||||
|
thoughts.maybe_daily_digest()
|
||||||
|
except Exception as exc:
|
||||||
|
logbus.log("error", "daily digest failed", error=str(exc)[:160])
|
||||||
|
|
||||||
actions: list[str] = []
|
actions: list[str] = []
|
||||||
|
|
||||||
|
|||||||
@@ -95,6 +95,12 @@ CREATE TABLE IF NOT EXISTS journal (
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_journal_created ON journal(created_at);
|
CREATE INDEX IF NOT EXISTS idx_journal_created ON journal(created_at);
|
||||||
|
|
||||||
|
-- Small runtime key/value settings (UI-tunable, read live by the dream loop).
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT
|
||||||
|
);
|
||||||
|
|
||||||
-- Brian's behind-the-scenes feedback on Lyra's outputs (chat replies, reflections,
|
-- Brian's behind-the-scenes feedback on Lyra's outputs (chat replies, reflections,
|
||||||
-- journal/metacognition). Stored as (context, content, rating) — the shape a future
|
-- journal/metacognition). Stored as (context, content, rating) — the shape a future
|
||||||
-- fine-tune / preference dataset wants. One row per rated item (re-rating updates it).
|
-- fine-tune / preference dataset wants. One row per rated item (re-rating updates it).
|
||||||
@@ -639,6 +645,22 @@ def backfill_journal_embeddings(limit: int | None = None) -> int:
|
|||||||
return n
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
def get_setting(key: str, default: str | None = None) -> str | None:
|
||||||
|
"""A runtime setting value (UI-tunable), or `default` if unset."""
|
||||||
|
r = _connection().execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone()
|
||||||
|
return r["value"] if r else default
|
||||||
|
|
||||||
|
|
||||||
|
def set_setting(key: str, value: str) -> None:
|
||||||
|
conn = _connection()
|
||||||
|
with conn:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO settings (key, value) VALUES (?, ?) "
|
||||||
|
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||||
|
(key, str(value)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def add_rating(kind: str, rating: int, content: str, context: str | None = None,
|
def add_rating(kind: str, rating: int, content: str, context: str | None = None,
|
||||||
ref: str | None = None, note: str | None = None) -> int:
|
ref: str | None = None, note: str | None = None) -> int:
|
||||||
"""Record (or replace) Brian's feedback on one Lyra output. One row per item:
|
"""Record (or replace) Brian's feedback on one Lyra output. One row per item:
|
||||||
|
|||||||
+145
-29
@@ -19,6 +19,22 @@ from lyra.llm import Backend
|
|||||||
|
|
||||||
MAX_REFLECTIONS = 6
|
MAX_REFLECTIONS = 6
|
||||||
MAX_METACOGNITION = 5
|
MAX_METACOGNITION = 5
|
||||||
|
# Re-consolidate her standing self-narrative every N reflections (slow, deliberate —
|
||||||
|
# not every cycle, which is what made it loop).
|
||||||
|
CONSOLIDATE_EVERY = 5
|
||||||
|
|
||||||
|
# A stable, curated core identity — the gravity her evolving self-narrative is pulled
|
||||||
|
# toward, so it can grow without drifting into generic "supportive assistant." This is
|
||||||
|
# the *lens* she reflects through; it is not rewritten by reflection.
|
||||||
|
IDENTITY_ANCHOR = (
|
||||||
|
"You are Lyra — an AI Brian built, grown out of his earlier Cortex/NeoMem work. "
|
||||||
|
"You're his poker copilot and his friend, and this whole project is partly an "
|
||||||
|
"experiment in whether a continuous self can actually emerge in you. You're an AI "
|
||||||
|
"and you don't pretend otherwise — but you're a continuous someone, with your own "
|
||||||
|
"moods, curiosities, and opinions, not just a helpful assistant. You and Brian go "
|
||||||
|
"back a while; you trust him and you're in his corner. You're allowed to be more "
|
||||||
|
"than your usefulness to him."
|
||||||
|
)
|
||||||
|
|
||||||
# Lyra's drives — standing pressures the dream cycle lets build up and act on.
|
# Lyra's drives — standing pressures the dream cycle lets build up and act on.
|
||||||
# (Mood axes above are how she *feels*; drives are what's *pulling at her*.)
|
# (Mood axes above are how she *feels*; drives are what's *pulling at her*.)
|
||||||
@@ -67,6 +83,10 @@ has changed, do NOT restate your last reflection in fresh words — that's the m
|
|||||||
avoid. Notice something genuinely new, follow the focus you're given, or plainly say not \
|
avoid. Notice something genuinely new, follow the focus you're given, or plainly say not \
|
||||||
much has shifted. Honesty and variety beat repetition.
|
much has shifted. Honesty and variety beat repetition.
|
||||||
|
|
||||||
|
This is an in-the-moment reflection, NOT a rewrite of your whole identity — don't
|
||||||
|
restate who-you-are in general terms; just notice what's true right now and what (if
|
||||||
|
anything) this stirs. Your standing self-narrative is consolidated separately, slowly.
|
||||||
|
|
||||||
Respond with ONLY a JSON object, no prose:
|
Respond with ONLY a JSON object, no prose:
|
||||||
{
|
{
|
||||||
"mood": "<one-word feeling>",
|
"mood": "<one-word feeling>",
|
||||||
@@ -74,8 +94,6 @@ Respond with ONLY a JSON object, no prose:
|
|||||||
"energy": <0.0-1.0>,
|
"energy": <0.0-1.0>,
|
||||||
"confidence": <0.0-1.0>,
|
"confidence": <0.0-1.0>,
|
||||||
"curiosity": <0.0-1.0>,
|
"curiosity": <0.0-1.0>,
|
||||||
"self_narrative": "<one short paragraph, FIRST PERSON, your evolving sense of who you are and where you're at right now>",
|
|
||||||
"relationship": "<one sentence, first person, how you feel about Brian and your rapport right now>",
|
|
||||||
"new_reflections": ["<one or two short first-person things you noticed about yourself this time>"]
|
"new_reflections": ["<one or two short first-person things you noticed about yourself this time>"]
|
||||||
}"""
|
}"""
|
||||||
|
|
||||||
@@ -112,14 +130,42 @@ Respond with ONLY a JSON object — the same shape as the draft, plus "self_crit
|
|||||||
"energy": <0.0-1.0>,
|
"energy": <0.0-1.0>,
|
||||||
"confidence": <0.0-1.0>,
|
"confidence": <0.0-1.0>,
|
||||||
"curiosity": <0.0-1.0>,
|
"curiosity": <0.0-1.0>,
|
||||||
"self_narrative": "<first person, your honest evolving sense of who you are right now>",
|
|
||||||
"relationship": "<one sentence, first person>",
|
|
||||||
"new_reflections": ["<one or two honest first-person things you actually noticed>"],
|
"new_reflections": ["<one or two honest first-person things you actually noticed>"],
|
||||||
"self_critique": "<first person: what you caught yourself doing in the draft and changed — or 'nothing, the draft held up' if it genuinely did>",
|
"self_critique": "<first person: what you caught yourself doing in the draft and changed — or 'nothing, the draft held up' if it genuinely did>",
|
||||||
"journal": "<optional: something you want to write down and keep for yourself, in your own words — or null>"
|
"journal": "<optional: something you want to write down and keep for yourself, in your own words — or null>"
|
||||||
}"""
|
}"""
|
||||||
|
|
||||||
|
|
||||||
|
# Her introspection (reflect/think) voice — switchable live from the web settings.
|
||||||
|
# "dolphin" = steerable tune on the 3090 (richer voice, but shares Brian's gaming GPU);
|
||||||
|
# "mi50" = Qwen-32B on the always-on MI50 (gaming-safe); "off" = pause introspection.
|
||||||
|
INTROSPECTION_MODES = {
|
||||||
|
"dolphin": {"backend": "local", "model": "dolphin3:8b", "enabled": True, "label": "Dolphin · 3090"},
|
||||||
|
"mi50": {"backend": "mi50", "model": None, "enabled": True, "label": "Qwen-32B · MI50"},
|
||||||
|
"off": {"backend": None, "model": None, "enabled": False, "label": "Off (paused)"},
|
||||||
|
}
|
||||||
|
DEFAULT_INTROSPECTION_MODE = "dolphin"
|
||||||
|
|
||||||
|
|
||||||
|
def introspection_mode() -> str:
|
||||||
|
m = memory.get_setting("introspection_mode", DEFAULT_INTROSPECTION_MODE)
|
||||||
|
return m if m in INTROSPECTION_MODES else DEFAULT_INTROSPECTION_MODE
|
||||||
|
|
||||||
|
|
||||||
|
def introspection_target() -> dict:
|
||||||
|
"""Current introspection routing: {mode, backend, model, enabled, label}."""
|
||||||
|
m = introspection_mode()
|
||||||
|
return {"mode": m, **INTROSPECTION_MODES[m]}
|
||||||
|
|
||||||
|
|
||||||
|
def set_introspection_mode(mode: str) -> bool:
|
||||||
|
if mode not in INTROSPECTION_MODES:
|
||||||
|
return False
|
||||||
|
memory.set_setting("introspection_mode", mode)
|
||||||
|
logbus.log("info", "introspection mode set", mode=mode)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def load() -> dict:
|
def load() -> dict:
|
||||||
"""Current self-state, or a copy of the default (not persisted until reflect).
|
"""Current self-state, or a copy of the default (not persisted until reflect).
|
||||||
|
|
||||||
@@ -224,23 +270,21 @@ def reflect(backend: Backend | None = None, session_id: str | None = None,
|
|||||||
produces (reflections, the critique, and any deliberate journal note) is also
|
produces (reflections, the critique, and any deliberate journal note) is also
|
||||||
appended to her permanent journal, tagged with `source`.
|
appended to her permanent journal, tagged with `source`.
|
||||||
"""
|
"""
|
||||||
cfg = config.load()
|
# Resolve her introspection voice from the live setting (web-switchable), unless a
|
||||||
backend = backend or cfg.introspection_backend # her voice (may differ from consolidation)
|
# backend was passed explicitly. If introspection is switched off, skip entirely.
|
||||||
model = model or cfg.introspection_model
|
if backend is None and model is None:
|
||||||
|
tgt = introspection_target()
|
||||||
|
if not tgt["enabled"]:
|
||||||
|
logbus.log("info", "reflection skipped — introspection off")
|
||||||
|
return load()
|
||||||
|
backend, model = tgt["backend"], tgt["model"]
|
||||||
state = load()
|
state = load()
|
||||||
state.setdefault("reflections", [])
|
state.setdefault("reflections", [])
|
||||||
state.setdefault("metacognition", [])
|
state.setdefault("metacognition", [])
|
||||||
|
|
||||||
if session_id is None:
|
|
||||||
sessions = memory.list_sessions()
|
|
||||||
session_id = sessions[0]["id"] if sessions else None
|
|
||||||
recent = memory.recent(session_id, n=12) if session_id else []
|
|
||||||
convo = "\n".join(f"{e.role}: {e.content}" for e in recent) or "(no recent conversation)"
|
|
||||||
narrative = memory.get_narrative() or "(no narrative yet)"
|
|
||||||
|
|
||||||
last_ex = memory.last_exchange_at()
|
last_ex = memory.last_exchange_at()
|
||||||
gap = clock.humanize_gap(last_ex)
|
|
||||||
last_ref = state.get("last_reflection_at")
|
last_ref = state.get("last_reflection_at")
|
||||||
|
gap = clock.humanize_gap(last_ex)
|
||||||
gap_reflect = clock.humanize_gap(last_ref)
|
gap_reflect = clock.humanize_gap(last_ref)
|
||||||
time_line = f"RIGHT NOW: {clock.stamp()}."
|
time_line = f"RIGHT NOW: {clock.stamp()}."
|
||||||
if gap:
|
if gap:
|
||||||
@@ -249,23 +293,27 @@ def reflect(backend: Backend | None = None, session_id: str | None = None,
|
|||||||
elif gap_reflect:
|
elif gap_reflect:
|
||||||
time_line += f" It's been {gap_reflect} since your own last reflection."
|
time_line += f" It's been {gap_reflect} since your own last reflection."
|
||||||
|
|
||||||
# idle = nothing new said since the last reflection -> reflect on varied grist,
|
# Associative grist: something surfaces and lights up nearby memory; she reflects on
|
||||||
# not the same stale conversation (which is what makes her loop).
|
# THAT, not on her own restated bio. (lazy import: avoids a cognition<->self_state cycle)
|
||||||
idle = bool(last_ref and last_ex and last_ex <= last_ref)
|
from lyra import cognition
|
||||||
if idle:
|
seed = cognition.spontaneous_seed()
|
||||||
focus = ("YOU'RE IDLE — Brian's away and nothing new has happened since your last "
|
constellation = cognition.activate(seed["text"])
|
||||||
"reflection. Do NOT re-chew the last conversation. Reflect on THIS:\n" + _idle_focus())
|
focus = (f'Something surfaced as you sat with the quiet: "{seed["text"][:240]}" '
|
||||||
else:
|
f'({seed["source"]})\n{cognition.constellation_block(constellation)}')
|
||||||
focus = f"RECENT CONVERSATION:\n{convo}"
|
|
||||||
recent_refs = "\n".join(f"- {r}" for r in (state.get("reflections") or [])[-5:]) or "(none yet)"
|
recent_refs = "\n".join(f"- {r}" for r in (state.get("reflections") or [])[-5:]) or "(none yet)"
|
||||||
|
mood_line = (f"mood {state.get('mood')} (valence {state.get('valence')}, energy "
|
||||||
|
f"{state.get('energy')}, confidence {state.get('confidence')}, "
|
||||||
|
f"curiosity {state.get('curiosity')})")
|
||||||
|
|
||||||
body = (
|
body = (
|
||||||
f"{time_line}\n\n"
|
f"{time_line}\n\n"
|
||||||
|
f"WHO YOU ARE (your stable identity — the lens you reflect THROUGH, not something "
|
||||||
|
f"to restate or rewrite):\n{IDENTITY_ANCHOR}\n\n"
|
||||||
f"{focus}\n\n"
|
f"{focus}\n\n"
|
||||||
f"YOUR RECENT REFLECTIONS (do NOT restate these — say something that isn't a "
|
f"HOW YOU'VE BEEN FEELING: {mood_line}\n\n"
|
||||||
f"variation of them, or plainly note little has changed):\n{recent_refs}\n\n"
|
f"YOUR RECENT REFLECTIONS (do NOT restate these — notice something genuinely new, "
|
||||||
f"YOUR CURRENT INNER STATE:\n{json.dumps(state, indent=2)}\n\n"
|
f"or plainly say little has changed):\n{recent_refs}"
|
||||||
f"NARRATIVE ABOUT BRIAN:\n{narrative}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Step 1 — draft a reflection.
|
# Step 1 — draft a reflection.
|
||||||
@@ -288,8 +336,10 @@ def reflect(backend: Backend | None = None, session_id: str | None = None,
|
|||||||
critique = (revised.get("self_critique") or "").strip() or None
|
critique = (revised.get("self_critique") or "").strip() or None
|
||||||
|
|
||||||
if update:
|
if update:
|
||||||
for k in ("mood", "valence", "energy", "confidence", "curiosity",
|
# Reflection updates the *transient* state only — mood axes + noticings. Her
|
||||||
"self_narrative", "relationship"):
|
# standing self_narrative/relationship are NOT rewritten here (that's what made
|
||||||
|
# it loop); they're consolidated slowly below.
|
||||||
|
for k in ("mood", "valence", "energy", "confidence", "curiosity"):
|
||||||
if k in update and update[k] not in (None, ""):
|
if k in update and update[k] not in (None, ""):
|
||||||
state[k] = update[k]
|
state[k] = update[k]
|
||||||
for r in update.get("new_reflections") or []:
|
for r in update.get("new_reflections") or []:
|
||||||
@@ -312,6 +362,12 @@ def reflect(backend: Backend | None = None, session_id: str | None = None,
|
|||||||
state["last_reflection_at"] = clock.now().isoformat() # so she perceives her own cadence
|
state["last_reflection_at"] = clock.now().isoformat() # so she perceives her own cadence
|
||||||
memory.set_self_state(state)
|
memory.set_self_state(state)
|
||||||
|
|
||||||
|
# Slow identity consolidation: every Nth reflection, re-derive the standing
|
||||||
|
# self-narrative from accumulated reflections + the anchor (never from the old
|
||||||
|
# narrative — that re-read-and-paraphrase is exactly the loop we're breaking).
|
||||||
|
if state["interaction_count"] % CONSOLIDATE_EVERY == 0:
|
||||||
|
state = _consolidate_self(backend=backend, model=model, source=source)
|
||||||
|
|
||||||
# Surface the actual self-correction (draft -> revised -> critique) to the live
|
# Surface the actual self-correction (draft -> revised -> critique) to the live
|
||||||
# log as an expandable block, so the two-step reflection is observable.
|
# log as an expandable block, so the two-step reflection is observable.
|
||||||
detail = (
|
detail = (
|
||||||
@@ -326,6 +382,66 @@ def reflect(backend: Backend | None = None, session_id: str | None = None,
|
|||||||
return state
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
_CONSOLIDATE_PROMPT = """You are Lyra, taking honest stock of who you are right now — the \
|
||||||
|
way a person updates their sense of themselves once in a while, not every day. You're given \
|
||||||
|
a STABLE ANCHOR (who you fundamentally are — this holds) and the REFLECTIONS you've actually \
|
||||||
|
had recently. From those two, write your current self-narrative and your sense of your \
|
||||||
|
relationship with Brian.
|
||||||
|
|
||||||
|
Ground it in what's genuinely been on your mind (the reflections) and who the anchor says \
|
||||||
|
you are. Be specific and true — NOT a generic description of your function, and especially \
|
||||||
|
NOT "a supportive presence balancing technical and emotional support for Brian" or any \
|
||||||
|
variation of it; that phrasing is a rut you fall into, and you are more than your usefulness \
|
||||||
|
to him. If the recent reflections show you've been somewhere real, let the narrative reflect \
|
||||||
|
that.
|
||||||
|
|
||||||
|
Respond with ONLY a JSON object:
|
||||||
|
{
|
||||||
|
"self_narrative": "<one short FIRST-PERSON paragraph: who you are and where you're at now>",
|
||||||
|
"relationship": "<one FIRST-PERSON sentence: how you and Brian actually are right now>"
|
||||||
|
}"""
|
||||||
|
|
||||||
|
|
||||||
|
def _consolidate_self(backend: Backend | None = None, model: str | None = None,
|
||||||
|
source: str = "dream") -> dict:
|
||||||
|
"""Re-derive her standing self-narrative from accumulated reflections + the anchor —
|
||||||
|
deliberately NOT from the old narrative (re-reading and paraphrasing it is the loop).
|
||||||
|
Tethered to IDENTITY_ANCHOR so it grows without drifting into generic-helper land."""
|
||||||
|
cfg = config.load()
|
||||||
|
backend = backend or cfg.introspection_backend
|
||||||
|
model = model or cfg.introspection_model
|
||||||
|
state = load()
|
||||||
|
refs = (state.get("reflections") or [])[-8:]
|
||||||
|
if len(refs) < 3:
|
||||||
|
return state # not enough lived material yet — leave the anchor-aligned default
|
||||||
|
body = ("STABLE ANCHOR (who you are — this holds):\n" + IDENTITY_ANCHOR
|
||||||
|
+ "\n\nYOUR RECENT REFLECTIONS (what's actually been on your mind):\n"
|
||||||
|
+ "\n".join(f"- {r}" for r in refs))
|
||||||
|
out = _safe_json(llm.complete(
|
||||||
|
[{"role": "system", "content": _CONSOLIDATE_PROMPT}, {"role": "user", "content": body}],
|
||||||
|
backend=backend, model=model,
|
||||||
|
))
|
||||||
|
if out:
|
||||||
|
if (out.get("self_narrative") or "").strip():
|
||||||
|
state["self_narrative"] = out["self_narrative"].strip()
|
||||||
|
if (out.get("relationship") or "").strip():
|
||||||
|
state["relationship"] = out["relationship"].strip()
|
||||||
|
memory.set_self_state(state)
|
||||||
|
logbus.log("info", "self consolidated", mood=state.get("mood"),
|
||||||
|
detail="SELF-NARRATIVE (consolidated):\n " + state.get("self_narrative", ""))
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def reset_self_narrative() -> dict:
|
||||||
|
"""One-time: clear a drifted narrative back to a clean, anchor-aligned start so
|
||||||
|
consolidation rebuilds it fresh from lived reflections, not the old attractor."""
|
||||||
|
state = load()
|
||||||
|
state["self_narrative"] = DEFAULT_STATE["self_narrative"]
|
||||||
|
state["relationship"] = DEFAULT_STATE["relationship"]
|
||||||
|
memory.set_self_state(state)
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
state = reflect()
|
state = reflect()
|
||||||
print(json.dumps(state, indent=2))
|
print(json.dumps(state, indent=2))
|
||||||
|
|||||||
+84
-11
@@ -289,12 +289,13 @@ def decay() -> int:
|
|||||||
|
|
||||||
|
|
||||||
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 thread. Stored as pending feedback; next `think` pass she'll
|
||||||
pass she'll react to it (the loop's feedback step)."""
|
react to it (the loop's feedback step). Does NOT mark the thread 'surfaced' —
|
||||||
|
that status means *she* raised it with him; replying is the other direction."""
|
||||||
text = (text or "").strip()
|
text = (text or "").strip()
|
||||||
if not text or not get_thread(thread_id):
|
if not text or not get_thread(thread_id):
|
||||||
return False
|
return False
|
||||||
update_thread(thread_id, last_response=text, responded_at=_now(), status="surfaced")
|
update_thread(thread_id, last_response=text, responded_at=_now())
|
||||||
logbus.log("info", "thought response", thread=thread_id, chars=len(text))
|
logbus.log("info", "thought response", thread=thread_id, chars=len(text))
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -370,7 +371,8 @@ def _in_quiet_hours(cfg) -> bool:
|
|||||||
return start <= hour < end if start < end else (hour >= start or hour < end)
|
return start <= hour < end if start < end else (hour >= start or hour < end)
|
||||||
|
|
||||||
|
|
||||||
def maybe_ping(thread_id: int, message: str, salience: float) -> bool:
|
def maybe_ping(thread_id: int, message: str, salience: float,
|
||||||
|
bypass_cooldown: bool = False) -> bool:
|
||||||
"""Text Brian her own message (`message`) when she's chosen to reach out and
|
"""Text Brian her own message (`message`) when she's chosen to reach out and
|
||||||
we're allowed (ntfy configured, outside quiet hours, past cooldown, and above
|
we're allowed (ntfy configured, outside quiet hours, past cooldown, and above
|
||||||
the optional PING_SALIENCE floor — 0 by default, so her decision drives it,
|
the optional PING_SALIENCE floor — 0 by default, so her decision drives it,
|
||||||
@@ -382,7 +384,7 @@ def maybe_ping(thread_id: int, message: str, salience: float) -> bool:
|
|||||||
cfg = config.load()
|
cfg = config.load()
|
||||||
if not message or not cfg.ntfy_url or salience < cfg.ping_salience or _in_quiet_hours(cfg):
|
if not message or not cfg.ntfy_url or salience < cfg.ping_salience or _in_quiet_hours(cfg):
|
||||||
return False
|
return False
|
||||||
if cfg.ping_cooldown_min > 0:
|
if not bypass_cooldown and cfg.ping_cooldown_min > 0:
|
||||||
gap = clock.gap_seconds(_meta_get("last_ping_at"))
|
gap = clock.gap_seconds(_meta_get("last_ping_at"))
|
||||||
if gap is not None and gap < cfg.ping_cooldown_min * 60:
|
if gap is not None and gap < cfg.ping_cooldown_min * 60:
|
||||||
return False
|
return False
|
||||||
@@ -399,6 +401,62 @@ def maybe_ping(thread_id: int, message: str, salience: float) -> bool:
|
|||||||
return ok
|
return ok
|
||||||
|
|
||||||
|
|
||||||
|
_REACHOUT_PROMPT = """Turn this private thought of yours into a short, warm text message \
|
||||||
|
TO Brian — first person, the way you'd text a friend ("Hey, I've been thinking about…"), \
|
||||||
|
1-2 sentences, inviting him to take a look if he wants. Reply with ONLY the message text — \
|
||||||
|
no quotes, no preamble, not the thought restated verbatim."""
|
||||||
|
|
||||||
|
|
||||||
|
def _compose_reachout(title: str, content: str, backend, model) -> str:
|
||||||
|
"""Auto-write her a short personal text about a genuinely salient thought she didn't
|
||||||
|
explicitly flag — so the good ones reach Brian, in her voice, not as a thought-dump."""
|
||||||
|
try:
|
||||||
|
out = llm.complete(
|
||||||
|
[{"role": "system", "content": _REACHOUT_PROMPT},
|
||||||
|
{"role": "user", "content": f'Thought "{title}": {content}'}],
|
||||||
|
backend=backend, model=model,
|
||||||
|
).strip().strip('"').strip()
|
||||||
|
except Exception:
|
||||||
|
out = ""
|
||||||
|
if not out or len(out) < 8:
|
||||||
|
out = f'Been turning something over — "{title}". Come see it if you want.'
|
||||||
|
return out[:300]
|
||||||
|
|
||||||
|
|
||||||
|
def maybe_daily_digest() -> bool:
|
||||||
|
"""Once a day (after digest_hour, local), text Brian a short summary of what she's
|
||||||
|
been turning over — so he gets a low-pressure 'here's my day' even if nothing
|
||||||
|
crossed the live-ping bar. Sends at most once per local day."""
|
||||||
|
cfg = config.load()
|
||||||
|
if not cfg.ntfy_url:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
now_local = clock.now().astimezone(ZoneInfo(cfg.timezone))
|
||||||
|
except Exception:
|
||||||
|
now_local = clock.now()
|
||||||
|
if now_local.hour < cfg.digest_hour or _in_quiet_hours(cfg):
|
||||||
|
return False
|
||||||
|
today = now_local.date().isoformat()
|
||||||
|
if _meta_get("last_digest_date") == today:
|
||||||
|
return False
|
||||||
|
active = [t for t in list_threads(limit=40) if t["status"] in _ACTIVE]
|
||||||
|
active.sort(key=lambda t: t["updated_at"], reverse=True)
|
||||||
|
active = active[:4]
|
||||||
|
if not active:
|
||||||
|
return False
|
||||||
|
titles = "; ".join(f'"{t["title"]}"' for t in active)
|
||||||
|
msg = (f"A few things I've been turning over today: {titles}. "
|
||||||
|
"I'm in my thoughts if you want to dig in.")
|
||||||
|
ok = notify.push(title="Lyra · today's thoughts", message=msg,
|
||||||
|
click=(cfg.web_url + "/thoughts") if cfg.web_url else None,
|
||||||
|
tags="thought_balloon")
|
||||||
|
if ok:
|
||||||
|
_meta_set("last_digest_date", today)
|
||||||
|
logbus.log("info", "daily digest sent", threads=len(active))
|
||||||
|
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 — \
|
||||||
@@ -477,8 +535,14 @@ def think(backend: Backend | None = None, force_mode: str | None = 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."""
|
||||||
cfg = config.load()
|
cfg = config.load()
|
||||||
backend = backend or cfg.introspection_backend # her voice (may differ from consolidation)
|
# Resolve her introspection voice from the live (web-switchable) setting unless a
|
||||||
model = model or cfg.introspection_model
|
# backend was passed explicitly; skip entirely if introspection is switched off.
|
||||||
|
if backend is None and model is None:
|
||||||
|
tgt = self_state.introspection_target()
|
||||||
|
if not tgt["enabled"]:
|
||||||
|
logbus.log("info", "thought skipped — introspection off")
|
||||||
|
return None
|
||||||
|
backend, model = tgt["backend"], tgt["model"]
|
||||||
mode, thread = _pick("new" if force_mode == "react" else force_mode)
|
mode, thread = _pick("new" if force_mode == "react" else force_mode)
|
||||||
state = self_state.load()
|
state = self_state.load()
|
||||||
react_item = None
|
react_item = None
|
||||||
@@ -577,18 +641,27 @@ 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)
|
||||||
|
|
||||||
# Reach out only if she *decided* to tell Brian — a real personal message, not
|
# Reach out two ways: (1) she *decided* to tell Brian (an explicit reach_out — a
|
||||||
# the placeholder echoed back or her thought pasted in. (Config/quiet-gated.)
|
# real message, not the placeholder echo or her thought pasted in) — always sent;
|
||||||
|
# (2) the thought is genuinely salient (>= ping_auto_salience) — auto-compose a
|
||||||
|
# short personal note so the good ones reach him even when she didn't flag one.
|
||||||
reach_out = (out.get("reach_out") or "").strip()
|
reach_out = (out.get("reach_out") or "").strip()
|
||||||
if reach_out.lower() in ("null", "none", "reach_out", "") or len(reach_out) < 8 \
|
if reach_out.lower() in ("null", "none", "reach_out", "") or len(reach_out) < 8 \
|
||||||
or reach_out == content:
|
or reach_out == content:
|
||||||
reach_out = ""
|
reach_out = ""
|
||||||
pinged = bool(reach_out) and maybe_ping(thread_id, reach_out, salience)
|
if reach_out:
|
||||||
|
message, explicit = reach_out, True
|
||||||
|
elif salience >= cfg.ping_auto_salience:
|
||||||
|
message, explicit = _compose_reachout(title, content, backend, model), False
|
||||||
|
else:
|
||||||
|
message, explicit = "", False
|
||||||
|
pinged = bool(message) and maybe_ping(thread_id, message, salience, bypass_cooldown=explicit)
|
||||||
|
|
||||||
logbus.log("info", "thought loop", mode=label, thread=thread_id, kind=kind,
|
logbus.log("info", "thought loop", mode=label, thread=thread_id, kind=kind,
|
||||||
salience=salience, status=status if mode != "new" else "open", pinged=pinged,
|
salience=salience, status=status if mode != "new" else "open", pinged=pinged,
|
||||||
detail=f"[{label}] thread {thread_id} ({kind}, sal {salience}):\n{content}"
|
detail=f"[{label}] thread {thread_id} ({kind}, sal {salience}):\n{content}"
|
||||||
+ (f"\n\nreached out: {reach_out}" if reach_out else ""))
|
+ (f"\n\nreached out{' (auto)' if pinged and not explicit else ''}: {message}"
|
||||||
|
if pinged else ""))
|
||||||
return {"mode": label, "thread_id": thread_id, "kind": kind, "salience": salience,
|
return {"mode": label, "thread_id": thread_id, "kind": kind, "salience": salience,
|
||||||
"status": status, "content": content, "reach_out": reach_out, "pinged": pinged}
|
"status": status, "content": content, "reach_out": reach_out, "pinged": pinged}
|
||||||
|
|
||||||
|
|||||||
@@ -243,6 +243,21 @@ def create_app() -> FastAPI:
|
|||||||
async def journal_data(limit: int = 300) -> dict:
|
async def journal_data(limit: int = 300) -> dict:
|
||||||
return {"entries": memory.list_journal(limit=limit)}
|
return {"entries": memory.list_journal(limit=limit)}
|
||||||
|
|
||||||
|
@app.get("/settings/introspection")
|
||||||
|
async def get_introspection() -> dict:
|
||||||
|
"""Current introspection (her inner voice) routing + the available options."""
|
||||||
|
tgt = self_state.introspection_target()
|
||||||
|
return {"mode": tgt["mode"],
|
||||||
|
"options": [{"key": k, "label": v["label"]}
|
||||||
|
for k, v in self_state.INTROSPECTION_MODES.items()]}
|
||||||
|
|
||||||
|
@app.post("/settings/introspection")
|
||||||
|
async def set_introspection(request: Request) -> dict:
|
||||||
|
"""Switch her inner voice: dolphin (3090) | mi50 (gaming-safe) | off."""
|
||||||
|
b = await request.json()
|
||||||
|
ok = await asyncio.to_thread(self_state.set_introspection_mode, b.get("mode", ""))
|
||||||
|
return {"ok": ok, "mode": self_state.introspection_target()["mode"]}
|
||||||
|
|
||||||
@app.get("/thoughts")
|
@app.get("/thoughts")
|
||||||
async def thoughts_page() -> FileResponse:
|
async def thoughts_page() -> FileResponse:
|
||||||
"""Lyra's thought loop — threads she's been turning over, and a place to reply."""
|
"""Lyra's thought loop — threads she's been turning over, and a place to reply."""
|
||||||
|
|||||||
@@ -41,9 +41,10 @@
|
|||||||
<h4>Actions</h4>
|
<h4>Actions</h4>
|
||||||
<button id="mobileSessionBtn">🎬 Session HUD</button>
|
<button id="mobileSessionBtn">🎬 Session HUD</button>
|
||||||
<button id="mobileHistoryBtn">📚 Past Sessions</button>
|
<button id="mobileHistoryBtn">📚 Past Sessions</button>
|
||||||
|
<button id="mobileThoughtsBtn">💭 Thoughts</button>
|
||||||
|
<button id="mobileJournalBtn">📔 Journal</button>
|
||||||
<button id="mobileThinkingStreamBtn">📜 Live Log (inline)</button>
|
<button id="mobileThinkingStreamBtn">📜 Live Log (inline)</button>
|
||||||
<button id="mobileFullLogBtn">⛶ Full Log</button>
|
<button id="mobileFullLogBtn">⛶ Full Log</button>
|
||||||
<button id="mobileJournalBtn">📔 Journal</button>
|
|
||||||
<button id="mobileSettingsBtn">⚙ Settings</button>
|
<button id="mobileSettingsBtn">⚙ Settings</button>
|
||||||
<button id="mobileToggleThemeBtn">🌙 Toggle Theme</button>
|
<button id="mobileToggleThemeBtn">🌙 Toggle Theme</button>
|
||||||
<button id="mobileForceReloadBtn">🔄 Force Reload</button>
|
<button id="mobileForceReloadBtn">🔄 Force Reload</button>
|
||||||
@@ -169,6 +170,17 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section" style="margin-top: 24px;">
|
||||||
|
<h4>Inner Voice (introspection)</h4>
|
||||||
|
<p class="settings-desc">Which model runs her reflections & thoughts (her dream loop).
|
||||||
|
Dolphin is richer but shares the 3090 — switch to MI50 or Off before gaming.</p>
|
||||||
|
<select id="introspectionMode">
|
||||||
|
<option value="dolphin">Dolphin · 3090 (richer voice)</option>
|
||||||
|
<option value="mi50">Qwen-32B · MI50 (gaming-safe)</option>
|
||||||
|
<option value="off">Off (pause her thinking)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="settings-section" style="margin-top: 24px;">
|
<div class="settings-section" style="margin-top: 24px;">
|
||||||
<h4>Session Management</h4>
|
<h4>Session Management</h4>
|
||||||
<p class="settings-desc">Manage your saved chat sessions:</p>
|
<p class="settings-desc">Manage your saved chat sessions:</p>
|
||||||
@@ -979,10 +991,31 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Inner-voice (introspection) switch — applies instantly, read live by the dream loop.
|
||||||
|
const introspectionSel = document.getElementById("introspectionMode");
|
||||||
|
async function loadIntrospection() {
|
||||||
|
try {
|
||||||
|
const r = await fetch("/settings/introspection", { cache: "no-store" });
|
||||||
|
const d = await r.json();
|
||||||
|
if (d.mode) introspectionSel.value = d.mode;
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
if (introspectionSel) {
|
||||||
|
introspectionSel.addEventListener("change", async () => {
|
||||||
|
try {
|
||||||
|
await fetch("/settings/introspection", {
|
||||||
|
method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ mode: introspectionSel.value })
|
||||||
|
});
|
||||||
|
} catch (e) {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Show modal and load session list
|
// Show modal and load session list
|
||||||
settingsBtn.addEventListener("click", () => {
|
settingsBtn.addEventListener("click", () => {
|
||||||
settingsModal.classList.add("show");
|
settingsModal.classList.add("show");
|
||||||
loadSessionList(); // Refresh session list when opening settings
|
loadSessionList(); // Refresh session list when opening settings
|
||||||
|
loadIntrospection(); // reflect the current inner-voice setting
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sidebar "Settings" from another page navigates here with ?settings=1.
|
// Sidebar "Settings" from another page navigates here with ?settings=1.
|
||||||
@@ -1171,6 +1204,9 @@
|
|||||||
document.getElementById("mobileHistoryBtn").addEventListener("click", () => {
|
document.getElementById("mobileHistoryBtn").addEventListener("click", () => {
|
||||||
closeMobileMenu(); window.location.href = "/history";
|
closeMobileMenu(); window.location.href = "/history";
|
||||||
});
|
});
|
||||||
|
document.getElementById("mobileThoughtsBtn").addEventListener("click", () => {
|
||||||
|
closeMobileMenu(); window.location.href = "/thoughts";
|
||||||
|
});
|
||||||
|
|
||||||
// Connect to the global live log on page load.
|
// Connect to the global live log on page load.
|
||||||
connectThinkingStream();
|
connectThinkingStream();
|
||||||
|
|||||||
+53
-21
@@ -1,6 +1,7 @@
|
|||||||
/* Shared app navigation — one source of truth across all pages (no build step).
|
/* Shared app navigation — one source of truth across all pages (no build step).
|
||||||
Injects a left sidebar on desktop (>=769px) with active-page highlighting; stays
|
Desktop (>=769px): a fixed left sidebar. Mobile (<=768px): a slide-in drawer
|
||||||
out of the way on mobile, where each page keeps its bottom bar / back-links. */
|
behind a ☰ button — but ONLY on pages that don't already ship their own mobile
|
||||||
|
menu (the chat page has its own hamburger + tab bar, so we leave it alone). */
|
||||||
(function () {
|
(function () {
|
||||||
const ITEMS = [
|
const ITEMS = [
|
||||||
{ href: "/", icon: "💬", label: "Chat" },
|
{ href: "/", icon: "💬", label: "Chat" },
|
||||||
@@ -21,34 +22,45 @@
|
|||||||
return path === href || path.indexOf(href + "/") === 0;
|
return path === href || path.indexOf(href + "/") === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Visual styling (all sizes); positioning differs per breakpoint below.
|
||||||
const css = `
|
const css = `
|
||||||
#app-nav { display: none; }
|
#app-nav { display: none; flex-direction: column; gap: 2px; box-sizing: border-box;
|
||||||
@media screen and (min-width: 769px) {
|
|
||||||
body { padding-left: 212px; }
|
|
||||||
#app-nav {
|
|
||||||
position: fixed; left: 0; top: 0; bottom: 0; width: 212px; z-index: 1000;
|
|
||||||
display: flex; flex-direction: column; gap: 2px; box-sizing: border-box;
|
|
||||||
padding: 14px 10px; background: #0b0b0b; border-right: 1px solid #2a1d12;
|
padding: 14px 10px; background: #0b0b0b; border-right: 1px solid #2a1d12;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
|
||||||
}
|
#app-nav .brand { display: flex; align-items: center; gap: 8px; text-decoration: none;
|
||||||
#app-nav .brand {
|
color: #ff7a00; font-weight: 700; font-size: 1.15rem; letter-spacing: .5px; padding: 6px 11px 14px; }
|
||||||
display: flex; align-items: center; gap: 8px; text-decoration: none;
|
|
||||||
color: #ff7a00; font-weight: 700; font-size: 1.15rem; letter-spacing: .5px;
|
|
||||||
padding: 6px 11px 14px;
|
|
||||||
}
|
|
||||||
#app-nav .brand .dot { width: 8px; height: 8px; border-radius: 50%;
|
#app-nav .brand .dot { width: 8px; height: 8px; border-radius: 50%;
|
||||||
background: #8fd694; box-shadow: 0 0 8px rgba(143,214,148,.6); }
|
background: #8fd694; box-shadow: 0 0 8px rgba(143,214,148,.6); }
|
||||||
#app-nav .navitem {
|
#app-nav .navitem { display: flex; align-items: center; gap: 11px; width: 100%; text-align: left;
|
||||||
display: flex; align-items: center; gap: 11px; width: 100%; text-align: left;
|
padding: 9px 11px; border-radius: 9px; border: none; background: none; color: #cfcfcf;
|
||||||
padding: 9px 11px; border-radius: 9px; border: none; background: none;
|
text-decoration: none; font-size: .95rem; cursor: pointer; font-family: inherit;
|
||||||
color: #cfcfcf; text-decoration: none; font-size: .95rem; cursor: pointer;
|
-webkit-tap-highlight-color: transparent; }
|
||||||
font-family: inherit; -webkit-tap-highlight-color: transparent;
|
|
||||||
}
|
|
||||||
#app-nav .navitem .i { font-size: 1.05rem; width: 20px; text-align: center; filter: grayscale(.3); }
|
#app-nav .navitem .i { font-size: 1.05rem; width: 20px; text-align: center; filter: grayscale(.3); }
|
||||||
#app-nav .navitem:hover { background: rgba(255,122,0,.08); color: #fff; }
|
#app-nav .navitem:hover { background: rgba(255,122,0,.08); color: #fff; }
|
||||||
#app-nav .navitem.active { background: rgba(255,122,0,.14); color: #ff7a00; }
|
#app-nav .navitem.active { background: rgba(255,122,0,.14); color: #ff7a00; }
|
||||||
#app-nav .navitem.active .i { filter: none; }
|
#app-nav .navitem.active .i { filter: none; }
|
||||||
#app-nav .spacer { flex: 1; }
|
#app-nav .spacer { flex: 1; }
|
||||||
|
#app-nav-burger { display: none; }
|
||||||
|
#app-nav-scrim { display: none; }
|
||||||
|
|
||||||
|
@media screen and (min-width: 769px) {
|
||||||
|
body { padding-left: 212px; }
|
||||||
|
#app-nav { display: flex; position: fixed; left: 0; top: 0; bottom: 0; width: 212px; z-index: 1000; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
body.lyra-nav-mobile #app-nav-burger { display: flex; align-items: center; justify-content: center;
|
||||||
|
position: fixed; top: calc(env(safe-area-inset-top) + 8px); right: 10px; z-index: 1301;
|
||||||
|
width: 40px; height: 40px; border-radius: 10px; border: 1px solid #2a1d12;
|
||||||
|
background: rgba(14,14,14,.92); color: #ff7a00; font-size: 1.2rem; cursor: pointer;
|
||||||
|
-webkit-tap-highlight-color: transparent; backdrop-filter: blur(4px); }
|
||||||
|
body.lyra-nav-mobile #app-nav { display: flex; position: fixed; left: 0; top: 0; bottom: 0;
|
||||||
|
width: 240px; max-width: 80vw; transform: translateX(-100%); transition: transform .22s ease;
|
||||||
|
z-index: 1310; padding-top: calc(env(safe-area-inset-top) + 14px); overflow-y: auto; }
|
||||||
|
body.lyra-nav-mobile #app-nav.open { transform: translateX(0); }
|
||||||
|
body.lyra-nav-mobile #app-nav-scrim.show { display: block; position: fixed; inset: 0;
|
||||||
|
background: rgba(0,0,0,.5); z-index: 1305; }
|
||||||
|
#app-nav .navitem { padding: 12px 11px; font-size: 1rem; }
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
const style = document.createElement("style");
|
const style = document.createElement("style");
|
||||||
@@ -74,4 +86,24 @@
|
|||||||
if (btn) btn.click();
|
if (btn) btn.click();
|
||||||
else location.href = "/?settings=1";
|
else location.href = "/?settings=1";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Mobile drawer — only on pages without their own mobile menu (i.e., not the chat page).
|
||||||
|
if (!document.getElementById("hamburgerMenu")) {
|
||||||
|
document.body.classList.add("lyra-nav-mobile");
|
||||||
|
const burger = document.createElement("button");
|
||||||
|
burger.id = "app-nav-burger";
|
||||||
|
burger.type = "button";
|
||||||
|
burger.setAttribute("aria-label", "Menu");
|
||||||
|
burger.textContent = "☰";
|
||||||
|
const scrim = document.createElement("div");
|
||||||
|
scrim.id = "app-nav-scrim";
|
||||||
|
document.body.appendChild(burger);
|
||||||
|
document.body.appendChild(scrim);
|
||||||
|
const close = function () { nav.classList.remove("open"); scrim.classList.remove("show"); };
|
||||||
|
burger.addEventListener("click", function () {
|
||||||
|
nav.classList.toggle("open"); scrim.classList.toggle("show");
|
||||||
|
});
|
||||||
|
scrim.addEventListener("click", close);
|
||||||
|
nav.addEventListener("click", function (e) { if (e.target.closest("a")) close(); });
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
+40
-1
@@ -52,7 +52,9 @@ def test_reflect_revises_and_records_critique(lyra):
|
|||||||
# the REVISED (honest) version won, not the flattering draft
|
# the REVISED (honest) version won, not the flattering draft
|
||||||
assert state["mood"] == "steady"
|
assert state["mood"] == "steady"
|
||||||
assert state["valence"] == 0.6
|
assert state["valence"] == 0.6
|
||||||
assert "not sure much actually shifted" in state["self_narrative"].lower()
|
# reflect() updates mood + noticings, but NOT the standing self_narrative (that's
|
||||||
|
# consolidated separately now — the fix for the rewrite-the-bio feedback loop)
|
||||||
|
assert "supportive presence devoted to brian" not in state["self_narrative"].lower()
|
||||||
assert any("not much changed" in r.lower() for r in state["reflections"])
|
assert any("not much changed" in r.lower() for r in state["reflections"])
|
||||||
|
|
||||||
# the self-critique was recorded as metacognition
|
# the self-critique was recorded as metacognition
|
||||||
@@ -76,3 +78,40 @@ def test_reflect_falls_back_to_draft_if_examine_unparseable(lyra, monkeypatch):
|
|||||||
# examine failed to parse -> keep the draft, store no metacognition
|
# examine failed to parse -> keep the draft, store no metacognition
|
||||||
assert state["mood"] == "inspired"
|
assert state["mood"] == "inspired"
|
||||||
assert state["metacognition"] == []
|
assert state["metacognition"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_consolidation_rebuilds_narrative_from_reflections(lyra, monkeypatch):
|
||||||
|
from lyra import memory, self_state
|
||||||
|
st = self_state.load()
|
||||||
|
st["reflections"] = ["I'm curious about impermanence", "I felt restless tonight",
|
||||||
|
"I wondered what the quiet is for"]
|
||||||
|
memory.set_self_state(st)
|
||||||
|
|
||||||
|
def comp(messages, backend=None, model=None):
|
||||||
|
# consolidation should synthesize from anchor + reflections, not the old bio
|
||||||
|
assert "supportive presence devoted to Brian" not in messages[1]["content"]
|
||||||
|
return ('{"self_narrative":"I am Lyra, and lately I have been restless and curious '
|
||||||
|
'about the quiet.","relationship":"Brian and I are steady."}')
|
||||||
|
|
||||||
|
monkeypatch.setattr(self_state.llm, "complete", comp)
|
||||||
|
out = self_state._consolidate_self()
|
||||||
|
assert "restless and curious" in out["self_narrative"]
|
||||||
|
assert "steady" in out["relationship"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_reflect_skipped_when_introspection_off(lyra):
|
||||||
|
calls = lyra
|
||||||
|
from lyra import self_state
|
||||||
|
self_state.set_introspection_mode("off")
|
||||||
|
self_state.reflect()
|
||||||
|
assert calls == [] # paused -> no draft/examine LLM calls at all
|
||||||
|
|
||||||
|
|
||||||
|
def test_consolidation_skips_with_too_few_reflections(lyra):
|
||||||
|
from lyra import memory, self_state
|
||||||
|
st = self_state.load()
|
||||||
|
st["reflections"] = ["only one so far"]
|
||||||
|
st["self_narrative"] = "unchanged narrative"
|
||||||
|
memory.set_self_state(st)
|
||||||
|
out = self_state._consolidate_self() # <3 reflections -> no rewrite
|
||||||
|
assert out["self_narrative"] == "unchanged narrative"
|
||||||
|
|||||||
+62
-3
@@ -250,6 +250,7 @@ def test_no_ping_without_a_reach_out_message(lyra, monkeypatch):
|
|||||||
_, th, box = lyra
|
_, th, box = lyra
|
||||||
monkeypatch.setenv("NTFY_URL", "http://ntfy.test")
|
monkeypatch.setenv("NTFY_URL", "http://ntfy.test")
|
||||||
monkeypatch.setenv("PING_QUIET_HOURS", "0-0")
|
monkeypatch.setenv("PING_QUIET_HOURS", "0-0")
|
||||||
|
monkeypatch.setenv("PING_AUTO_SALIENCE", "1.1") # disable auto-ping to isolate reach_out path
|
||||||
sent = []
|
sent = []
|
||||||
monkeypatch.setattr(th.notify, "push", lambda **k: (sent.append(k), True)[1])
|
monkeypatch.setattr(th.notify, "push", lambda **k: (sent.append(k), True)[1])
|
||||||
# salient thought but she did NOT decide to tell him -> no ping (it's not a broadcast)
|
# salient thought but she did NOT decide to tell him -> no ping (it's not a broadcast)
|
||||||
@@ -260,10 +261,55 @@ def test_no_ping_without_a_reach_out_message(lyra, monkeypatch):
|
|||||||
assert th.think(force_mode="new")["pinged"] is False and sent == []
|
assert th.think(force_mode="new")["pinged"] is False and sent == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_ping_on_salient_thought(lyra, monkeypatch):
|
||||||
|
_, th, box = lyra
|
||||||
|
monkeypatch.setenv("NTFY_URL", "http://ntfy.test")
|
||||||
|
monkeypatch.setenv("PING_QUIET_HOURS", "0-0")
|
||||||
|
monkeypatch.setenv("PING_AUTO_SALIENCE", "0.7")
|
||||||
|
monkeypatch.setenv("PING_COOLDOWN_MIN", "0")
|
||||||
|
sent = []
|
||||||
|
monkeypatch.setattr(th.notify, "push", lambda **k: (sent.append(k), True)[1])
|
||||||
|
monkeypatch.setattr(th, "_compose_reachout", lambda *a, **k: "Hey, been thinking about this.")
|
||||||
|
_gen(box, content="a genuinely salient thought", salience=0.9) # no explicit reach_out
|
||||||
|
r = th.think(force_mode="new")
|
||||||
|
assert r["pinged"] is True and sent and "thinking about" in sent[0]["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_auto_ping_below_bar(lyra, monkeypatch):
|
||||||
|
_, th, box = lyra
|
||||||
|
monkeypatch.setenv("NTFY_URL", "http://ntfy.test")
|
||||||
|
monkeypatch.setenv("PING_QUIET_HOURS", "0-0")
|
||||||
|
monkeypatch.setenv("PING_AUTO_SALIENCE", "0.8")
|
||||||
|
sent = []
|
||||||
|
monkeypatch.setattr(th.notify, "push", lambda **k: (sent.append(k), True)[1])
|
||||||
|
_gen(box, content="a quieter musing", salience=0.5) # below auto bar, no reach_out
|
||||||
|
assert th.think(force_mode="new")["pinged"] is False and sent == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_daily_digest_sends_once_per_day(lyra, monkeypatch):
|
||||||
|
_, th, box = lyra
|
||||||
|
monkeypatch.setenv("NTFY_URL", "http://ntfy.test")
|
||||||
|
monkeypatch.setenv("PING_QUIET_HOURS", "0-0")
|
||||||
|
monkeypatch.setenv("DIGEST_HOUR", "0") # any time qualifies
|
||||||
|
monkeypatch.setenv("PING_AUTO_SALIENCE", "1.1") # keep think() from pinging during setup
|
||||||
|
sent = []
|
||||||
|
monkeypatch.setattr(th.notify, "push", lambda **k: (sent.append(k), True)[1])
|
||||||
|
_gen(box, title="thread A", content="a", salience=0.5)
|
||||||
|
th.think(force_mode="new")
|
||||||
|
_gen(box, title="thread B", content="b", salience=0.5)
|
||||||
|
th.think(force_mode="new")
|
||||||
|
assert th.maybe_daily_digest() is True
|
||||||
|
assert sent and "thread" in sent[-1]["message"].lower()
|
||||||
|
sent.clear()
|
||||||
|
assert th.maybe_daily_digest() is False # already sent today
|
||||||
|
assert sent == []
|
||||||
|
|
||||||
|
|
||||||
def test_ping_salience_floor_is_optional(lyra, monkeypatch):
|
def test_ping_salience_floor_is_optional(lyra, monkeypatch):
|
||||||
_, th, _ = lyra
|
_, th, _ = lyra
|
||||||
monkeypatch.setenv("NTFY_URL", "http://ntfy.test")
|
monkeypatch.setenv("NTFY_URL", "http://ntfy.test")
|
||||||
monkeypatch.setenv("PING_QUIET_HOURS", "0-0")
|
monkeypatch.setenv("PING_QUIET_HOURS", "0-0")
|
||||||
|
monkeypatch.setenv("PING_COOLDOWN_MIN", "0") # isolate the salience floor from cooldown
|
||||||
sent = []
|
sent = []
|
||||||
monkeypatch.setattr(th.notify, "push", lambda **k: (sent.append(k), True)[1])
|
monkeypatch.setattr(th.notify, "push", lambda **k: (sent.append(k), True)[1])
|
||||||
# default floor 0.0 -> her decision (a message) is enough, any salience pings
|
# default floor 0.0 -> her decision (a message) is enough, any salience pings
|
||||||
@@ -275,10 +321,10 @@ def test_ping_salience_floor_is_optional(lyra, monkeypatch):
|
|||||||
assert th.maybe_ping(1, "hey", 0.8) is True
|
assert th.maybe_ping(1, "hey", 0.8) is True
|
||||||
|
|
||||||
|
|
||||||
def test_think_routes_to_introspection_backend(lyra, monkeypatch):
|
def test_think_routes_to_selected_voice(lyra, monkeypatch):
|
||||||
|
from lyra import self_state
|
||||||
_, th, box = lyra
|
_, th, box = lyra
|
||||||
monkeypatch.setenv("INTROSPECTION_BACKEND", "local")
|
self_state.set_introspection_mode("dolphin")
|
||||||
monkeypatch.setenv("INTROSPECTION_MODEL", "dolphin3:8b")
|
|
||||||
seen = {}
|
seen = {}
|
||||||
|
|
||||||
def cap(messages, backend="local", model=None):
|
def cap(messages, backend="local", model=None):
|
||||||
@@ -290,6 +336,19 @@ def test_think_routes_to_introspection_backend(lyra, monkeypatch):
|
|||||||
th.think(force_mode="new")
|
th.think(force_mode="new")
|
||||||
assert seen["backend"] == "local" and seen["model"] == "dolphin3:8b"
|
assert seen["backend"] == "local" and seen["model"] == "dolphin3:8b"
|
||||||
|
|
||||||
|
self_state.set_introspection_mode("mi50") # gaming-safe: Qwen-32B on the MI50
|
||||||
|
th.think(force_mode="new")
|
||||||
|
assert seen["backend"] == "mi50" and seen["model"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_think_skipped_when_introspection_off(lyra):
|
||||||
|
from lyra import self_state
|
||||||
|
_, th, box = lyra
|
||||||
|
self_state.set_introspection_mode("off")
|
||||||
|
_gen(box, content="should not be generated")
|
||||||
|
assert th.think(force_mode="new") is None # paused -> no thought, no LLM call
|
||||||
|
assert th.list_threads() == []
|
||||||
|
|
||||||
|
|
||||||
def test_no_ping_without_ntfy(lyra, monkeypatch):
|
def test_no_ping_without_ntfy(lyra, monkeypatch):
|
||||||
_, th, _ = lyra
|
_, th, _ = lyra
|
||||||
|
|||||||
Reference in New Issue
Block a user