Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fc623db27a | |||
| 2a73033eed | |||
| aae8204eff | |||
| d6f3516a34 | |||
| 51c2d6abb9 | |||
| 8a3c9b2701 | |||
| 17ab95dc98 | |||
| 03aceec6fa | |||
| a7af461cdb | |||
| 904eda3388 | |||
| f1f15972ac | |||
| 97afa82594 | |||
| ea30c3dd67 | |||
| 149e9a6dd5 | |||
| cf4238911e | |||
| 3dd9eb5a3e | |||
| a7966e4bab | |||
| a705e573a9 |
@@ -45,3 +45,9 @@ 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
|
||||||
|
CHAT_DELIBERATE=true # think privately before answering substantive chat turns (false = faster, shallower)
|
||||||
|
MOUTH_BACKEND= # mind/mouth split: separate character/voice model for the final reply (empty = mind speaks)
|
||||||
|
MOUTH_MODEL=
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
# Lyra — Cognition Architecture (sketch)
|
||||||
|
|
||||||
|
> The "society of mind" direction: instead of one giant model we keep nagging with
|
||||||
|
> stricter prompts, a society of small specialized parts cooperate to produce each
|
||||||
|
> turn. **Most parts are cheap deterministic code (heuristics, math, learnable
|
||||||
|
> weights); the LLM is the exception, reserved for the few irreducibly-generative
|
||||||
|
> jobs.** Everything is anchored to who she is and tuned by feedback.
|
||||||
|
|
||||||
|
## Principles
|
||||||
|
|
||||||
|
1. **LLM is the exception, not the rule.** Bookkeeping, scoring, routing,
|
||||||
|
thresholding, retrieval → code. Generation (language, novel reasoning, memory
|
||||||
|
compression) → LLM, called sparingly.
|
||||||
|
2. **Mind ≠ Mouth.** A capable "mind" (decide / reason / use tools — helpfulness is
|
||||||
|
fine) is separate from a "mouth" (the character voice). This lets each be the
|
||||||
|
best model for *its* job — and makes the eventual fine-tune easy: you only have
|
||||||
|
to teach a small model to *sound like Lyra*, not to *be smart*.
|
||||||
|
3. **Anchored.** A fixed identity anchor governs the mouth so self-composed prompts
|
||||||
|
can't drift into generic-helper vapor. (Already exists: `self_state.IDENTITY_ANCHOR`.)
|
||||||
|
4. **Tuned by feedback, not just hand-tuning.** Learnable *weights* (over register,
|
||||||
|
memory, parts) nudged by 👍/👎 give real adaptation *without* fine-tuning a model.
|
||||||
|
5. **Allocation is the craft.** Cheap-deterministic where signal is clear; LLM where
|
||||||
|
judgment/language is needed; **hybrid** (heuristic common-case, escalate to LLM on
|
||||||
|
ambiguity) where possible.
|
||||||
|
|
||||||
|
## The blackboard: `TurnContext`
|
||||||
|
|
||||||
|
Parts don't call each other directly — they read from and write to a shared turn
|
||||||
|
state (a blackboard). Heterogeneous parts (heuristic / LLM / weights) cooperate by
|
||||||
|
annotating it. The composer reads the finished blackboard to build the prompt.
|
||||||
|
|
||||||
|
```
|
||||||
|
TurnContext {
|
||||||
|
# --- inputs ---
|
||||||
|
user_msg, session_id, history, now
|
||||||
|
|
||||||
|
# --- perception (heuristic) ---
|
||||||
|
moment : { kind: emotional|strategic|casual|existential|meta,
|
||||||
|
sentiment: -1..1, tilt: 0..1, urgency: 0..1 }
|
||||||
|
|
||||||
|
# --- state (code) ---
|
||||||
|
mood, drives, anchor
|
||||||
|
|
||||||
|
# --- retrieval (math: embeddings + cosine) ---
|
||||||
|
recalled : [memories] # spreading activation
|
||||||
|
threads : [active thoughts]
|
||||||
|
profile, narrative
|
||||||
|
|
||||||
|
# --- control (heuristic + learnable weights) ---
|
||||||
|
register : warm | coach | dry | tender | hype # how to sound
|
||||||
|
intent : console | push_back | teach | riff | act
|
||||||
|
mode : talk | cash | ... # tool allow-list
|
||||||
|
use_tools: bool
|
||||||
|
route : { mind: <model>, mouth: <model> } # which model per role
|
||||||
|
|
||||||
|
# --- generation (LLM, sparing) ---
|
||||||
|
deliberation : "her private thinking" # mind
|
||||||
|
tool_results : [...] # mind + tool exec
|
||||||
|
reply : "final text" # mouth
|
||||||
|
|
||||||
|
# --- learning (heuristic/online) ---
|
||||||
|
weights : { register_prefs, memory_weights, ... } # persisted, feedback-tuned
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## The parts
|
||||||
|
|
||||||
|
| # | Part | Type | Does | Exists today? |
|
||||||
|
|---|------|------|------|---------------|
|
||||||
|
| 1 | **perceive** | heuristic | sentiment + classify the moment + tilt/urgency from session signals & his language | ✗ (new) |
|
||||||
|
| 2 | **recall** | math | embeddings → relevant memories, active threads, profile, narrative | ✓ `memory.recall*`, `cognition.activate` |
|
||||||
|
| 3 | **sense_state** | code | load mood / drives / anchor | ✓ `self_state`, `IDENTITY_ANCHOR` |
|
||||||
|
| 4 | **route** | heuristic + weights | pick register, intent, mode, and which model is mind vs mouth | ✗ (new; partly `modes`) |
|
||||||
|
| 5 | **decide+act (tools)** | LLM (mind) / code | does this turn need a tool? run it | ✓ tool loop in `chat` |
|
||||||
|
| 6 | **deliberate** | LLM (mind) | "what do I actually think" — private substance pass | ✓ `chat._deliberate` |
|
||||||
|
| 7 | **compose** | code | assemble the final prompt from anchor + register + intent + deliberation + recall + tool results + voice rules | ✓ `build_messages` (becomes the composer) |
|
||||||
|
| 8 | **speak** | LLM (mouth) | write the reply in her voice, streamed, anchored | ✓ `llm.chat_call` |
|
||||||
|
| 9 | **learn** | heuristic/online | on 👍/👎 or reaction, nudge `weights` (which register/memory worked) | ✗ (new; data exists in `ratings`) |
|
||||||
|
|
||||||
|
Most of the society (1,2,3,4,7,9) is **free, instant, deterministic, debuggable.**
|
||||||
|
The LLM shows up in only ~2–3 places (5/6 = mind, 8 = mouth).
|
||||||
|
|
||||||
|
## One chat turn
|
||||||
|
|
||||||
|
```
|
||||||
|
user msg
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[1 perceive]──heuristic: emotional? strategic? tilting? (free)
|
||||||
|
│
|
||||||
|
[2 recall]───math: what lights up (memories, threads) (free)
|
||||||
|
[3 sense]────code: mood, drives, anchor (free)
|
||||||
|
│
|
||||||
|
[4 route]────heuristic+weights: register? intent? mind/mouth? (free)
|
||||||
|
│
|
||||||
|
[5 act]──────MIND model: tools if needed ─────────────┐ (LLM, only if needed)
|
||||||
|
[6 deliberate]──MIND model: what do I actually think │ (LLM, gated)
|
||||||
|
│ │
|
||||||
|
[7 compose]──code: build the prompt ◄──── anchor ──────┘ (free)
|
||||||
|
│
|
||||||
|
[8 speak]────MOUTH model: the reply, in her voice, streamed (LLM)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
reply ──► (later) [9 learn]: 👍/👎 nudges weights (free, async)
|
||||||
|
```
|
||||||
|
|
||||||
|
## What we reuse vs. build
|
||||||
|
|
||||||
|
- **Reuse (already scattered through the code):** recall/activation, self_state +
|
||||||
|
anchor, drives (in `dream`), modes (tool gating), the deliberation pass, the
|
||||||
|
prompt assembly (`build_messages`), tool loop, ratings store.
|
||||||
|
- **Build new:** the `TurnContext` blackboard + an explicit pipeline runner; the
|
||||||
|
**perceive** heuristic; the **route** part (register/intent + model routing); the
|
||||||
|
**learn** weights loop. Mostly *unifying* existing pieces into one legible control
|
||||||
|
plane, plus 2–3 small heuristic parts.
|
||||||
|
|
||||||
|
## Phasing (smallest first)
|
||||||
|
|
||||||
|
- **P1 — frame:** define `TurnContext`, refactor the current chat turn into the
|
||||||
|
explicit pipeline (perceive=stub → recall → sense → route=mode-only → deliberate →
|
||||||
|
compose → speak), single model. Low-risk refactor; makes the structure real.
|
||||||
|
- **P2 — control plane:** real `perceive` (sentiment/moment) + `route`
|
||||||
|
(register/intent). Now her framing adapts to the moment, deterministically.
|
||||||
|
- **P3 — mind/mouth split:** route picks a separate voice model for `speak`. Plug a
|
||||||
|
character mouth (Claude / local / later a fine-tune). A/B vs. single-model.
|
||||||
|
- **P4 — learning:** `weights` over register/memory, nudged by ratings → cheap
|
||||||
|
adaptation, no fine-tune.
|
||||||
|
- **P5 — her voice:** a small fine-tuned "Lyra voice" model drops into the mouth slot.
|
||||||
|
|
||||||
|
## Open decisions
|
||||||
|
|
||||||
|
- **Mouth model**: Claude (warm, cloud) vs. local character vs. fine-tune. The mouth
|
||||||
|
is the crux; it must render richly (8B local may flatten).
|
||||||
|
- **perceive**: pure heuristics vs. a tiny classifier vs. embedding-to-exemplar
|
||||||
|
clusters. Probably hybrid.
|
||||||
|
- **scheduler**: fixed linear pipeline (simple, v1) vs. drive-based/parallel later.
|
||||||
|
- **tool location**: mind decides+runs tools, mouth only renders (clean split) — vs.
|
||||||
|
letting the mouth call tools (needs a tool-capable mouth).
|
||||||
|
- **latency budget**: how many LLM calls per turn is acceptable live (cheap mind +
|
||||||
|
streamed mouth keeps it ~2).
|
||||||
|
```
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# Decision log — Decide mode's learning layer
|
||||||
|
|
||||||
|
Built overnight on `feat/decision-log`. This is the **data layer + tools only**. The
|
||||||
|
prompt/mode wiring (the taste part) is left for you on purpose — no persona/card edits
|
||||||
|
were made.
|
||||||
|
|
||||||
|
## The idea
|
||||||
|
|
||||||
|
Decide mode is currently a one-shot tie-breaker. The learning layer gives it memory:
|
||||||
|
log the call Brian actually makes, record how it turned out, and recall similar past
|
||||||
|
calls so a new recommendation leans on his own track record instead of generic advice.
|
||||||
|
|
||||||
|
Lifecycle: **log** (when the call is made) → **resolve** (later, with the outcome) →
|
||||||
|
**recall** (next time something similar comes up).
|
||||||
|
|
||||||
|
## What's built
|
||||||
|
|
||||||
|
**Storage** (`lyra/memory.py`):
|
||||||
|
- `decisions` table — situation, options, choice, rationale, confidence (1-5), tags,
|
||||||
|
embedding (over situation+choice), outcome, outcome_rating (-1/0/+1), resolved_at.
|
||||||
|
- `Decision` dataclass (with a `.resolved` property).
|
||||||
|
- `log_decision(...) -> id`, `resolve_decision(id, outcome, rating) -> bool`,
|
||||||
|
`get_decision(id)`, `list_decisions(limit, open_only)`,
|
||||||
|
`recall_decisions(query, k)` (cosine over embeddings, each hit carries `.score`).
|
||||||
|
- Embedding failures never block a log (blob just stays NULL).
|
||||||
|
|
||||||
|
**Tools** (`lyra/tools.py`) — handlers + specs, wired into `dispatch`:
|
||||||
|
- `log_decision` (situation, choice, options?, rationale?, confidence?, tags?)
|
||||||
|
- `resolve_decision` (decision_id, outcome, rating?)
|
||||||
|
- `recall_decisions` (query, k?) — returns past calls with their verdicts
|
||||||
|
|
||||||
|
**Tests** (`tests/test_decisions.py`) — 9, covering roundtrip, resolve, open-only
|
||||||
|
filtering, similarity ranking, and all three tool handlers. Full suite green, ruff clean.
|
||||||
|
|
||||||
|
## What's left for you (the wiring)
|
||||||
|
|
||||||
|
1. **Allow-list** — add the three tools to `_DECIDE_TOOLS` in `lyra/modes.py`
|
||||||
|
(and decide whether `recall_decisions` also belongs in Study). One-liner, but it's
|
||||||
|
the gate that lets her actually call them.
|
||||||
|
2. **Decide card guidance** — tell her *when* to use them: recall similar decisions
|
||||||
|
before recommending, log once Brian commits to a call, and circle back to resolve
|
||||||
|
open ones. This is the part I didn't want to touch without you (no bandaids).
|
||||||
|
3. **Optional surfacing** — open/unresolved decisions are a natural thing for her to
|
||||||
|
raise (thought loop / ping), and a small UI panel could list them. Not built.
|
||||||
|
4. **Optional auto-prompt to resolve** — the dream loop could notice decisions that
|
||||||
|
have been open a while and nudge for an outcome.
|
||||||
|
|
||||||
|
Nothing here changes behavior until step 1 — the tools exist but no mode offers them.
|
||||||
+108
-225
@@ -1,220 +1,63 @@
|
|||||||
"""The chat turn loop: persona + tiered memory + recent context -> reply.
|
"""The chat turn: assemble the prompt (lyra.mind) then speak + persist.
|
||||||
|
|
||||||
Context is assembled in tiers (oldest/most-compacted first):
|
`mind.assemble()` runs the society of parts (perceive → route → compose →
|
||||||
1. persona
|
deliberate) and hands back a ready message list + the active mode. Then:
|
||||||
2. long-term gist — relevant *summaries* of other sessions
|
- the MIND (the chat backend/model) runs the tool/generation loop — decide,
|
||||||
3. sharp details — a few raw cross-session exchanges (so specifics survive)
|
reason, run tools — and produces a draft.
|
||||||
4. recent raw turns of the current session (full fidelity)
|
- the MOUTH (a separate character model, if configured) re-voices that draft in
|
||||||
5. the new user message
|
her own voice. Default: no mouth configured → the mind's draft IS the reply
|
||||||
After replying, the session is compacted if enough new turns have accumulated.
|
(bit-for-bit the old behavior). The mouth slot is where a fine-tuned voice lands.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from lyra import clock, config, llm, logbus, memory, modes, persona, self_state, summary, thoughts
|
from lyra import config, llm, logbus, memory, mind, modes, summary
|
||||||
from lyra import tools as toolkit
|
from lyra import tools as toolkit
|
||||||
from lyra.llm import Backend, Message
|
from lyra.llm import Backend
|
||||||
|
|
||||||
RECALL_K = 3 # raw cross-session "sharp detail" hits
|
|
||||||
RECENT_N = 10 # raw turns of the current session
|
|
||||||
SUMMARY_K = 3 # other-session gists
|
|
||||||
MAX_TOOL_ROUNDS = 5 # cap tool-call iterations per turn
|
MAX_TOOL_ROUNDS = 5 # cap tool-call iterations per turn
|
||||||
# Backends that support function-calling. The MI50's llama.cpp server only does
|
# Backends that support function-calling. The MI50's llama.cpp server only does
|
||||||
# tools when launched with --jinja; until it is, keep tools to cloud so MI50 chat
|
# tools when launched with --jinja; until it is, keep tools to cloud so MI50 chat
|
||||||
# doesn't 500 on the tools param. Add "mi50" here once that flag is set.
|
# doesn't 500 on the tools param. Add "mi50" here once that flag is set.
|
||||||
TOOL_BACKENDS = {"cloud"}
|
TOOL_BACKENDS = {"cloud"}
|
||||||
|
_TANGLED = "(I got tangled using my tools there — say that again?)"
|
||||||
|
|
||||||
|
|
||||||
def _mode_state_note(mode: modes.Mode | None) -> str | None:
|
def _resolve_model(backend: Backend, model_override: str | None, cfg) -> str:
|
||||||
"""Dynamic, per-turn state for the active mode. Currently: surface Alligator
|
"""Live chat uses the stronger chat_model on cloud; local/mi50 use their own.
|
||||||
Blood while it's engaged on the live session, so she stays in that register."""
|
The UI's cloud-model picker only applies on the cloud backend."""
|
||||||
if not mode or mode.key != modes.CASH.key:
|
|
||||||
return None
|
|
||||||
from lyra import poker # local import: keep the core/domain coupling at call time
|
|
||||||
if poker.alligator_active():
|
|
||||||
return (
|
|
||||||
"🐊 ALLIGATOR BLOOD is ON for this session. Coach Brian in that register: "
|
|
||||||
"hang around, refuse to die, don't force miracles, make opponents beat him "
|
|
||||||
"correctly. Tough, patient, steady — no heroics, no spew, no quitting."
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _maybe_switch_mode(session_id: str, tool_name: str) -> None:
|
|
||||||
"""Keep the chat framing aligned with the live data: opening a poker session
|
|
||||||
auto-flips this chat into Cash mode (so the next turn gets the cash card + the
|
|
||||||
full live toolset). Manual UI switching still overrides anytime."""
|
|
||||||
if tool_name == "start_session":
|
|
||||||
memory.set_session_mode(session_id, modes.CASH.key)
|
|
||||||
logbus.log("info", "mode auto-switch", session=session_id, mode=modes.CASH.key)
|
|
||||||
|
|
||||||
|
|
||||||
def _summary_note(summaries: list[memory.Summary]) -> Message:
|
|
||||||
lines = [f"- ({(s.session_started_at or s.created_at)[:10]}) {s.content}" for s in summaries]
|
|
||||||
body = "Gist of earlier sessions (compacted — ask if you need specifics):\n" + "\n".join(lines)
|
|
||||||
return {"role": "system", "content": body}
|
|
||||||
|
|
||||||
|
|
||||||
def _detail_note(exchanges: list[memory.Exchange]) -> Message:
|
|
||||||
lines = [f"- ({ex.created_at[:10]}, {ex.role}) {ex.content}" for ex in exchanges]
|
|
||||||
body = "Specific things you recall from past conversations:\n" + "\n".join(lines)
|
|
||||||
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:
|
|
||||||
"""Current wall-clock time + how long since Brian last said anything.
|
|
||||||
|
|
||||||
Stated as plain fact — she has no clock otherwise, so without this 'now' and
|
|
||||||
the gap since the last turn are invisible to her.
|
|
||||||
"""
|
|
||||||
line = f"The current date and time is {clock.stamp()}."
|
|
||||||
gap = clock.humanize_gap(memory.last_exchange_at())
|
|
||||||
line += (
|
|
||||||
f" It has been {gap} since Brian last spoke with you."
|
|
||||||
if gap else " This is the first thing Brian has ever said to you."
|
|
||||||
)
|
|
||||||
return {"role": "system", "content": line}
|
|
||||||
|
|
||||||
|
|
||||||
def _render(messages: list[Message]) -> str:
|
|
||||||
"""Human-readable dump of the exact prompt, for the live-log inspector."""
|
|
||||||
return "\n\n".join(f"[{m['role']}]\n{m['content']}" for m in messages)
|
|
||||||
|
|
||||||
|
|
||||||
def build_messages(session_id: str, user_msg: str,
|
|
||||||
mode: modes.Mode | None = None) -> list[Message]:
|
|
||||||
"""Assemble the full, tiered message list for one turn."""
|
|
||||||
messages: list[Message] = [{"role": "system", "content": persona.system_prompt()}]
|
|
||||||
|
|
||||||
# Autonomy Core: Lyra's own evolving interiority (mood, self-narrative). Comes
|
|
||||||
# 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())})
|
|
||||||
|
|
||||||
# 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 —
|
|
||||||
# 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).
|
|
||||||
if mode and mode.card:
|
|
||||||
messages.append({"role": "system", "content": mode.card})
|
|
||||||
|
|
||||||
# Live ritual state (e.g. Alligator Blood ON) — dynamic, so it rides alongside
|
|
||||||
# the static card and keeps her in-register for the whole stretch, not just the
|
|
||||||
# turn she flipped it.
|
|
||||||
state_note = _mode_state_note(mode)
|
|
||||||
if state_note:
|
|
||||||
messages.append({"role": "system", "content": state_note})
|
|
||||||
|
|
||||||
# When she is: current time + the gap since Brian last spoke (she has no clock).
|
|
||||||
messages.append(_now_note())
|
|
||||||
|
|
||||||
# Thought loop: if Brian's been away and one of her own threads has built past
|
|
||||||
# the surface bar, let her lead with it (once). This is her #6 — bringing what
|
|
||||||
# she thought about while alone *to* him. Runs before the world-model tiers so
|
|
||||||
# it's framed as her interiority, like the self-state.
|
|
||||||
surfaced = thoughts.maybe_surface(memory.last_exchange_at())
|
|
||||||
if surfaced:
|
|
||||||
messages.append({"role": "system", "content": surfaced})
|
|
||||||
|
|
||||||
# Semantic memory: the distilled profile (who Brian is) — answers identity
|
|
||||||
# questions that raw recall can't. Always in context when it exists.
|
|
||||||
profile = memory.get_profile()
|
|
||||||
if profile:
|
|
||||||
messages.append(
|
|
||||||
{"role": "system", "content": "What you know about Brian:\n" + profile}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Time-aware memory: the current narrative (recent arc, trends, callbacks).
|
|
||||||
narrative = memory.get_narrative()
|
|
||||||
if narrative:
|
|
||||||
messages.append(
|
|
||||||
{"role": "system", "content": "What's going on with Brian lately:\n" + narrative}
|
|
||||||
)
|
|
||||||
|
|
||||||
recent = memory.recent(session_id, n=RECENT_N)
|
|
||||||
recent_ids = {ex.id for ex in recent}
|
|
||||||
|
|
||||||
# Tier 1: compacted gists of *other* sessions (long-term, general idea).
|
|
||||||
summaries = memory.recall_summaries(user_msg, k=SUMMARY_K, exclude_session=session_id)
|
|
||||||
if summaries:
|
|
||||||
messages.append(_summary_note(summaries))
|
|
||||||
|
|
||||||
# Tier 2: a few sharp raw details from other sessions (so specifics survive
|
|
||||||
# compaction). Skip the current session (its raw turns are in `recent`).
|
|
||||||
recalled = [
|
|
||||||
ex for ex in memory.recall(user_msg, k=RECALL_K)
|
|
||||||
if ex.id not in recent_ids and ex.session_id != session_id
|
|
||||||
]
|
|
||||||
if recalled:
|
|
||||||
messages.append(_detail_note(recalled))
|
|
||||||
|
|
||||||
# Tier 3: current session, full fidelity.
|
|
||||||
for ex in recent:
|
|
||||||
messages.append({"role": ex.role, "content": ex.content})
|
|
||||||
|
|
||||||
messages.append({"role": "user", "content": user_msg})
|
|
||||||
|
|
||||||
logbus.log(
|
|
||||||
"debug", "context built",
|
|
||||||
recent=len(recent), summaries=len(summaries), details=len(recalled),
|
|
||||||
chars=sum(len(m["content"]) for m in messages), detail=_render(messages),
|
|
||||||
)
|
|
||||||
return messages
|
|
||||||
|
|
||||||
|
|
||||||
def respond(session_id: str, user_msg: str, backend: Backend = "cloud",
|
|
||||||
model_override: str | None = None) -> str:
|
|
||||||
"""Produce Lyra's reply to a single user message and persist the exchange.
|
|
||||||
|
|
||||||
`model_override` (from the UI's cloud-model picker) only applies on the cloud
|
|
||||||
backend; local/mi50 keep their own configured models.
|
|
||||||
"""
|
|
||||||
cfg = config.load()
|
|
||||||
# Live chat uses the stronger chat_model on cloud (bulk consolidation keeps
|
|
||||||
# cloud_model). local/mi50 use their own configured model.
|
|
||||||
model = {"local": cfg.local_model, "cloud": cfg.chat_model, "mi50": cfg.mi50_model}.get(
|
model = {"local": cfg.local_model, "cloud": cfg.chat_model, "mi50": cfg.mi50_model}.get(
|
||||||
backend, backend
|
backend, backend
|
||||||
)
|
)
|
||||||
if model_override and backend == "cloud":
|
if model_override and backend == "cloud":
|
||||||
model = model_override
|
model = model_override
|
||||||
logbus.log(
|
return model
|
||||||
"info", "chat request", session=session_id, backend=backend,
|
|
||||||
model=model, embed=cfg.embed_backend,
|
|
||||||
)
|
|
||||||
|
|
||||||
mode = modes.get(memory.get_session_mode(session_id))
|
|
||||||
messages = build_messages(session_id, user_msg, mode=mode)
|
|
||||||
|
|
||||||
# Tool loop: offer Lyra her tools (scoped to the mode); if she calls one, run it
|
def _mouth_target(cfg, mind_backend: Backend, mind_model: str | None):
|
||||||
# and feed the result back so she can continue, until she returns a text reply.
|
"""The mouth (backend, model) if configured AND different from the mind; else None
|
||||||
tool_specs = toolkit.specs(mode.tools) if backend in TOOL_BACKENDS else None
|
(mouth == mind → no separate voice pass)."""
|
||||||
ctx = {"session_id": session_id, "backend": backend}
|
if not cfg.mouth_backend and not cfg.mouth_model:
|
||||||
|
return None
|
||||||
|
backend = cfg.mouth_backend or mind_backend
|
||||||
|
model = cfg.mouth_model or None
|
||||||
|
if backend == mind_backend and model == mind_model:
|
||||||
|
return None
|
||||||
|
return backend, model
|
||||||
|
|
||||||
|
|
||||||
|
def _maybe_switch_mode(session_id: str, tool_name: str) -> None:
|
||||||
|
"""Opening a poker session auto-flips this chat into Poker mode. Manual UI switching
|
||||||
|
still overrides anytime."""
|
||||||
|
if tool_name == "start_session":
|
||||||
|
memory.set_session_mode(session_id, modes.CASH.key)
|
||||||
|
logbus.log("info", "mode auto-switch", session=session_id, mode=modes.CASH.key)
|
||||||
|
|
||||||
|
|
||||||
|
def _mind_loop(messages, backend: Backend, model: str | None, tool_specs,
|
||||||
|
ctx: dict, session_id: str) -> tuple[str, list[str]]:
|
||||||
|
"""Run the tool/generation loop on the MIND model (non-streaming). Mutates
|
||||||
|
`messages` with tool calls/results. Returns (draft_reply, tool_names_run)."""
|
||||||
|
tools_run: list[str] = []
|
||||||
reply = ""
|
reply = ""
|
||||||
for _ in range(MAX_TOOL_ROUNDS):
|
for _ in range(MAX_TOOL_ROUNDS):
|
||||||
assistant_msg, tool_calls = llm.chat_call(
|
assistant_msg, tool_calls = llm.chat_call(
|
||||||
@@ -223,47 +66,70 @@ def respond(session_id: str, user_msg: str, backend: Backend = "cloud",
|
|||||||
if not tool_calls:
|
if not tool_calls:
|
||||||
reply = assistant_msg.get("content") or ""
|
reply = assistant_msg.get("content") or ""
|
||||||
break
|
break
|
||||||
messages.append(assistant_msg) # her tool-call request
|
messages.append(assistant_msg)
|
||||||
for tc in tool_calls:
|
for tc in tool_calls:
|
||||||
result = toolkit.dispatch(tc["name"], tc["arguments"], ctx)
|
result = toolkit.dispatch(tc["name"], tc["arguments"], ctx)
|
||||||
logbus.log("info", "tool call", session=session_id, tool=tc["name"], result=result[:80])
|
logbus.log("info", "tool call", session=session_id, tool=tc["name"], result=result[:80])
|
||||||
messages.append({"role": "tool", "tool_call_id": tc["id"], "content": result})
|
messages.append({"role": "tool", "tool_call_id": tc["id"], "content": result})
|
||||||
_maybe_switch_mode(session_id, tc["name"])
|
_maybe_switch_mode(session_id, tc["name"])
|
||||||
|
tools_run.append(tc["name"])
|
||||||
|
return reply, tools_run
|
||||||
|
|
||||||
|
|
||||||
|
def _voice_pass(messages, draft: str, backend: Backend, model: str | None) -> str:
|
||||||
|
"""Mouth: re-render the mind's draft in her voice. Falls back to the draft on failure."""
|
||||||
|
try:
|
||||||
|
out = llm.complete(mind.voice_messages(messages, draft), backend=backend, model=model)
|
||||||
|
return (out or "").strip() or draft
|
||||||
|
except Exception as exc:
|
||||||
|
logbus.log("error", "voice pass failed", error=str(exc)[:160])
|
||||||
|
return draft
|
||||||
|
|
||||||
|
|
||||||
|
def respond(session_id: str, user_msg: str, backend: Backend = "cloud",
|
||||||
|
model_override: str | None = None) -> str:
|
||||||
|
"""Produce Lyra's reply to a single user message and persist the exchange."""
|
||||||
|
cfg = config.load()
|
||||||
|
model = _resolve_model(backend, model_override, cfg)
|
||||||
|
logbus.log("info", "chat request", session=session_id, backend=backend,
|
||||||
|
model=model, embed=cfg.embed_backend)
|
||||||
|
|
||||||
|
turn = mind.assemble(session_id, user_msg, backend, model)
|
||||||
|
messages = turn.messages
|
||||||
|
tool_specs = toolkit.specs(turn.mode.tools) if backend in TOOL_BACKENDS else None
|
||||||
|
ctx = {"session_id": session_id, "backend": backend}
|
||||||
|
|
||||||
|
reply, _ = _mind_loop(messages, backend, model, tool_specs, ctx, session_id)
|
||||||
|
mouth = _mouth_target(cfg, backend, model)
|
||||||
|
if mouth and reply:
|
||||||
|
reply = _voice_pass(messages, reply, *mouth)
|
||||||
if not reply:
|
if not reply:
|
||||||
reply = "(I got tangled using my tools there — say that again?)"
|
reply = _TANGLED
|
||||||
logbus.log("info", "reply", session=session_id, chars=len(reply))
|
logbus.log("info", "reply", session=session_id, chars=len(reply), voiced=bool(mouth))
|
||||||
|
|
||||||
memory.remember(session_id, "user", user_msg)
|
memory.remember(session_id, "user", user_msg)
|
||||||
memory.remember(session_id, "assistant", reply)
|
memory.remember(session_id, "assistant", reply)
|
||||||
|
summary.maybe_summarize_async(session_id) # compact once enough new turns pile up
|
||||||
# Compact this session once enough new turns have piled up.
|
|
||||||
summary.maybe_summarize_async(session_id)
|
|
||||||
return reply
|
return reply
|
||||||
|
|
||||||
|
|
||||||
def respond_stream(session_id: str, user_msg: str, backend: Backend = "cloud",
|
def respond_stream(session_id: str, user_msg: str, backend: Backend = "cloud",
|
||||||
model_override: str | None = None):
|
model_override: str | None = None):
|
||||||
"""Streaming generator version of `respond`.
|
"""Streaming generator version of `respond`. Yields ("delta", text), ("tool", name),
|
||||||
|
and a final ("done", reply). Same side effects as `respond`."""
|
||||||
Yields ("delta", text) as content streams in, and ("tool", name) when a tool
|
|
||||||
runs. Persists the full exchange and yields a final ("done", reply) — matching
|
|
||||||
`respond`'s side effects (memory + compaction) exactly.
|
|
||||||
"""
|
|
||||||
cfg = config.load()
|
cfg = config.load()
|
||||||
model = {"local": cfg.local_model, "cloud": cfg.chat_model, "mi50": cfg.mi50_model}.get(
|
model = _resolve_model(backend, model_override, cfg)
|
||||||
backend, backend
|
logbus.log("info", "chat request (stream)", session=session_id, backend=backend,
|
||||||
)
|
model=model, embed=cfg.embed_backend)
|
||||||
if model_override and backend == "cloud":
|
|
||||||
model = model_override
|
|
||||||
logbus.log(
|
|
||||||
"info", "chat request (stream)", session=session_id, backend=backend,
|
|
||||||
model=model, embed=cfg.embed_backend,
|
|
||||||
)
|
|
||||||
|
|
||||||
mode = modes.get(memory.get_session_mode(session_id))
|
turn = mind.assemble(session_id, user_msg, backend, model)
|
||||||
messages = build_messages(session_id, user_msg, mode=mode)
|
messages = turn.messages
|
||||||
tool_specs = toolkit.specs(mode.tools) if backend in TOOL_BACKENDS else None
|
tool_specs = toolkit.specs(turn.mode.tools) if backend in TOOL_BACKENDS else None
|
||||||
ctx = {"session_id": session_id, "backend": backend}
|
ctx = {"session_id": session_id, "backend": backend}
|
||||||
|
mouth = _mouth_target(cfg, backend, model)
|
||||||
|
|
||||||
|
if mouth is None:
|
||||||
|
# No separate voice: stream the mind directly (the original path, unchanged).
|
||||||
parts: list[str] = []
|
parts: list[str] = []
|
||||||
for _ in range(MAX_TOOL_ROUNDS):
|
for _ in range(MAX_TOOL_ROUNDS):
|
||||||
assistant_msg = None
|
assistant_msg = None
|
||||||
@@ -280,20 +146,37 @@ def respond_stream(session_id: str, user_msg: str, backend: Backend = "cloud",
|
|||||||
tool_calls = payload
|
tool_calls = payload
|
||||||
if not tool_calls:
|
if not tool_calls:
|
||||||
break
|
break
|
||||||
messages.append(assistant_msg) # her tool-call request
|
messages.append(assistant_msg)
|
||||||
for tc in tool_calls:
|
for tc in tool_calls:
|
||||||
result = toolkit.dispatch(tc["name"], tc["arguments"], ctx)
|
result = toolkit.dispatch(tc["name"], tc["arguments"], ctx)
|
||||||
logbus.log("info", "tool call", session=session_id, tool=tc["name"], result=result[:80])
|
logbus.log("info", "tool call", session=session_id, tool=tc["name"], result=result[:80])
|
||||||
messages.append({"role": "tool", "tool_call_id": tc["id"], "content": result})
|
messages.append({"role": "tool", "tool_call_id": tc["id"], "content": result})
|
||||||
_maybe_switch_mode(session_id, tc["name"])
|
_maybe_switch_mode(session_id, tc["name"])
|
||||||
yield ("tool", tc["name"])
|
yield ("tool", tc["name"])
|
||||||
|
|
||||||
reply = "".join(parts)
|
reply = "".join(parts)
|
||||||
if not reply:
|
if not reply:
|
||||||
reply = "(I got tangled using my tools there — say that again?)"
|
reply = _TANGLED
|
||||||
|
yield ("delta", reply)
|
||||||
|
else:
|
||||||
|
# Mind decides + runs tools (non-streamed); mouth re-voices, streamed.
|
||||||
|
draft, tools_run = _mind_loop(messages, backend, model, tool_specs, ctx, session_id)
|
||||||
|
for name in tools_run:
|
||||||
|
yield ("tool", name)
|
||||||
|
parts = []
|
||||||
|
try:
|
||||||
|
for ev, payload in llm.chat_call_stream(
|
||||||
|
mind.voice_messages(messages, draft), backend=mouth[0], model=mouth[1], tools=None
|
||||||
|
):
|
||||||
|
if ev == "delta":
|
||||||
|
parts.append(payload)
|
||||||
|
yield ("delta", payload)
|
||||||
|
except Exception as exc:
|
||||||
|
logbus.log("error", "voice stream failed", error=str(exc)[:160])
|
||||||
|
reply = "".join(parts).strip() or draft or _TANGLED
|
||||||
|
if not parts:
|
||||||
yield ("delta", reply)
|
yield ("delta", reply)
|
||||||
logbus.log("info", "reply", session=session_id, chars=len(reply))
|
|
||||||
|
|
||||||
|
logbus.log("info", "reply", session=session_id, chars=len(reply), voiced=bool(mouth))
|
||||||
memory.remember(session_id, "user", user_msg)
|
memory.remember(session_id, "user", user_msg)
|
||||||
memory.remember(session_id, "assistant", reply)
|
memory.remember(session_id, "assistant", reply)
|
||||||
summary.maybe_summarize_async(session_id)
|
summary.maybe_summarize_async(session_id)
|
||||||
|
|||||||
+16
-3
@@ -32,9 +32,17 @@ 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
|
||||||
|
chat_deliberate: bool # think privately before answering substantive chat turns
|
||||||
|
# Mind/mouth split: the mind (the chat backend/model above) decides, reasons, and
|
||||||
|
# runs tools; the mouth re-voices the final reply in her character. Empty = mouth
|
||||||
|
# is the mind (no separate pass) — the slot for an eventual fine-tuned voice.
|
||||||
|
mouth_backend: str
|
||||||
|
mouth_model: str | None
|
||||||
# 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 +81,13 @@ 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")),
|
||||||
|
chat_deliberate=os.getenv("CHAT_DELIBERATE", "true").lower() not in ("0", "false", "no"),
|
||||||
|
mouth_backend=os.getenv("MOUTH_BACKEND", "").lower(),
|
||||||
|
mouth_model=os.getenv("MOUTH_MODEL") or None,
|
||||||
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] = []
|
||||||
|
|
||||||
|
|||||||
+14
-7
@@ -54,17 +54,24 @@ def _digest_month(gists: list[str], backend: Backend) -> str:
|
|||||||
return partials[0]
|
return partials[0]
|
||||||
|
|
||||||
|
|
||||||
def rebuild_eras(backend: Backend | None = None) -> dict:
|
def rebuild_eras(backend: Backend | None = None, force: bool = False) -> dict:
|
||||||
"""(Re)build a digest for every month that has session gists."""
|
"""Build a digest per month, but only for months whose session count changed since
|
||||||
|
the last build — old months don't change, so re-digesting them every consolidation
|
||||||
|
pass was pure wasted LLM work (and MI50 heat). `force=True` rebuilds everything."""
|
||||||
backend = backend or config.load().summary_backend
|
backend = backend or config.load().summary_backend
|
||||||
by_month = memory.summaries_by_month()
|
by_month = memory.summaries_by_month()
|
||||||
months = 0
|
have = {e.month: e.session_count for e in memory.list_eras()}
|
||||||
|
built = skipped = 0
|
||||||
for month in sorted(by_month):
|
for month in sorted(by_month):
|
||||||
|
n = len(by_month[month])
|
||||||
|
if not force and have.get(month) == n:
|
||||||
|
skipped += 1
|
||||||
|
continue # unchanged month — keep its existing digest
|
||||||
digest = _digest_month(by_month[month], backend)
|
digest = _digest_month(by_month[month], backend)
|
||||||
memory.store_era(month, digest, len(by_month[month]))
|
memory.store_era(month, digest, n)
|
||||||
months += 1
|
built += 1
|
||||||
logbus.log("info", "era built", month=month, sessions=len(by_month[month]))
|
logbus.log("info", "era built", month=month, sessions=n)
|
||||||
report = {"months": months}
|
report = {"built": built, "skipped": skipped, "months": built + skipped}
|
||||||
logbus.log("info", "eras complete", **report)
|
logbus.log("info", "eras complete", **report)
|
||||||
return report
|
return report
|
||||||
|
|
||||||
|
|||||||
+143
@@ -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).
|
||||||
@@ -109,6 +115,26 @@ CREATE TABLE IF NOT EXISTS ratings (
|
|||||||
note TEXT
|
note TEXT
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_ratings_created ON ratings(created_at);
|
CREATE INDEX IF NOT EXISTS idx_ratings_created ON ratings(created_at);
|
||||||
|
|
||||||
|
-- Decisions Lyra helped Brian make (Decide mode's learning layer). Logged when the
|
||||||
|
-- call is made; resolved later with how it actually turned out; recalled by semantic
|
||||||
|
-- similarity so a new call can lean on how similar ones went. embedding covers the
|
||||||
|
-- situation + choice. Resolved rows (with an outcome) are the signal worth recalling.
|
||||||
|
CREATE TABLE IF NOT EXISTS decisions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
situation TEXT NOT NULL, -- what was being decided
|
||||||
|
options TEXT, -- the alternatives weighed (free text / newline list)
|
||||||
|
choice TEXT NOT NULL, -- the call that was made
|
||||||
|
rationale TEXT, -- why
|
||||||
|
confidence INTEGER, -- 1-5, how sure at the time (nullable)
|
||||||
|
tags TEXT, -- domain: poker | life | build | ... (comma-separated)
|
||||||
|
embedding BLOB,
|
||||||
|
outcome TEXT, -- filled in on resolve: what actually happened
|
||||||
|
outcome_rating INTEGER, -- -1 bad / 0 mixed / +1 good (nullable until resolved)
|
||||||
|
resolved_at TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_decisions_created ON decisions(created_at);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_conn: sqlite3.Connection | None = None
|
_conn: sqlite3.Connection | None = None
|
||||||
@@ -178,6 +204,26 @@ class Era:
|
|||||||
score: float | None = None
|
score: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Decision:
|
||||||
|
id: int
|
||||||
|
created_at: str
|
||||||
|
situation: str
|
||||||
|
choice: str
|
||||||
|
options: str | None = None
|
||||||
|
rationale: str | None = None
|
||||||
|
confidence: int | None = None
|
||||||
|
tags: str | None = None
|
||||||
|
outcome: str | None = None
|
||||||
|
outcome_rating: int | None = None
|
||||||
|
resolved_at: str | None = None
|
||||||
|
score: float | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def resolved(self) -> bool:
|
||||||
|
return self.resolved_at is not None
|
||||||
|
|
||||||
|
|
||||||
def _to_blob(vec: list[float]) -> bytes:
|
def _to_blob(vec: list[float]) -> bytes:
|
||||||
return np.asarray(vec, dtype=np.float32).tobytes()
|
return np.asarray(vec, dtype=np.float32).tobytes()
|
||||||
|
|
||||||
@@ -639,6 +685,103 @@ def backfill_journal_embeddings(limit: int | None = None) -> int:
|
|||||||
return n
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
# --- decisions (Decide mode's learning layer) ---------------------------------
|
||||||
|
|
||||||
|
def _row_to_decision(r: sqlite3.Row) -> Decision:
|
||||||
|
return Decision(
|
||||||
|
id=r["id"], created_at=r["created_at"], situation=r["situation"],
|
||||||
|
choice=r["choice"], options=r["options"], rationale=r["rationale"],
|
||||||
|
confidence=r["confidence"], tags=r["tags"], outcome=r["outcome"],
|
||||||
|
outcome_rating=r["outcome_rating"], resolved_at=r["resolved_at"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def log_decision(situation: str, choice: str, options: str | None = None,
|
||||||
|
rationale: str | None = None, confidence: int | None = None,
|
||||||
|
tags: str | None = None) -> int:
|
||||||
|
"""Record a decision Brian made. Embeds situation+choice so similar future calls
|
||||||
|
can recall it. Returns the new row id. Resolve it later with resolve_decision."""
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
try:
|
||||||
|
[emb] = llm.embed([f"{situation}\nChose: {choice}"])
|
||||||
|
blob = _to_blob(emb)
|
||||||
|
except Exception:
|
||||||
|
blob = None # never block logging a decision on the embedder being down
|
||||||
|
conn = _connection()
|
||||||
|
with conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO decisions (created_at, situation, options, choice, rationale, "
|
||||||
|
"confidence, tags, embedding) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(now, situation, options, choice, rationale, confidence, tags, blob),
|
||||||
|
)
|
||||||
|
return int(cur.lastrowid)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_decision(decision_id: int, outcome: str, outcome_rating: int | None = None) -> bool:
|
||||||
|
"""Record how a past decision turned out. Returns False if the id is unknown."""
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
conn = _connection()
|
||||||
|
with conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
"UPDATE decisions SET outcome = ?, outcome_rating = ?, resolved_at = ? WHERE id = ?",
|
||||||
|
(outcome, outcome_rating, now, decision_id),
|
||||||
|
)
|
||||||
|
return cur.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def get_decision(decision_id: int) -> Decision | None:
|
||||||
|
r = _connection().execute("SELECT * FROM decisions WHERE id = ?", (decision_id,)).fetchone()
|
||||||
|
return _row_to_decision(r) if r else None
|
||||||
|
|
||||||
|
|
||||||
|
def list_decisions(limit: int = 20, open_only: bool = False) -> list[Decision]:
|
||||||
|
"""Recent decisions, newest first. open_only -> only those not yet resolved."""
|
||||||
|
sql = "SELECT * FROM decisions"
|
||||||
|
if open_only:
|
||||||
|
sql += " WHERE resolved_at IS NULL"
|
||||||
|
sql += " ORDER BY created_at DESC LIMIT ?"
|
||||||
|
rows = _connection().execute(sql, (limit,)).fetchall()
|
||||||
|
return [_row_to_decision(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def recall_decisions(query: str, k: int = 5) -> list[Decision]:
|
||||||
|
"""Top-k past decisions semantically similar to `query`, each with a `score` — so a
|
||||||
|
new call can lean on how similar ones went. Resolved rows carry the real signal."""
|
||||||
|
[q_vec] = llm.embed([query])
|
||||||
|
q = np.asarray(q_vec, dtype=np.float32)
|
||||||
|
rows = _connection().execute(
|
||||||
|
"SELECT * FROM decisions WHERE embedding IS NOT NULL"
|
||||||
|
).fetchall()
|
||||||
|
if not rows:
|
||||||
|
return []
|
||||||
|
matrix = np.stack([_from_blob(r["embedding"]) for r in rows])
|
||||||
|
norms = np.linalg.norm(matrix, axis=1)
|
||||||
|
scores = (matrix @ q) / (norms * np.linalg.norm(q) + 1e-9)
|
||||||
|
top_idx = np.argsort(scores)[::-1][:k]
|
||||||
|
out = []
|
||||||
|
for i in top_idx:
|
||||||
|
d = _row_to_decision(rows[i])
|
||||||
|
d.score = float(scores[i])
|
||||||
|
out.append(d)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
|||||||
+384
@@ -0,0 +1,384 @@
|
|||||||
|
"""The control plane: assemble one turn from a society of small parts.
|
||||||
|
|
||||||
|
This is the explicit version of what used to be inline in `chat.py`. A turn is
|
||||||
|
built by running an ordered pipeline of *parts* over a shared `TurnContext`
|
||||||
|
(blackboard): each part reads what it needs and annotates the context, and the
|
||||||
|
last steps produce the message list `chat` then hands to the voice model.
|
||||||
|
|
||||||
|
P1 (this): the frame, behavior-preserving. The parts wrap the existing logic —
|
||||||
|
perceive (stub) -> route (the session's mode) -> compose (tiered prompt) ->
|
||||||
|
deliberate (private 'what do I actually think' pass).
|
||||||
|
Later phases fill in perceive (read the moment), route (register/intent + model
|
||||||
|
routing), and a learn loop — see docs/COGNITION.md. Most parts are cheap
|
||||||
|
deterministic code; the LLM is the exception (deliberate here, speak in `chat`).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from lyra import clock, config, llm, logbus, memory, modes, perceive, persona, self_state, thoughts
|
||||||
|
from lyra.llm import Backend, Message
|
||||||
|
|
||||||
|
RECALL_K = 3 # raw cross-session "sharp detail" hits
|
||||||
|
RECENT_N = 10 # raw turns of the current session
|
||||||
|
SUMMARY_K = 3 # other-session gists
|
||||||
|
|
||||||
|
|
||||||
|
# --- prompt parts (compose) ----------------------------------------------
|
||||||
|
|
||||||
|
def _mode_state_note(mode: modes.Mode | None) -> str | None:
|
||||||
|
"""Dynamic, per-turn state for the active mode. Currently: surface Alligator
|
||||||
|
Blood while it's engaged on the live session, so she stays in that register."""
|
||||||
|
if not mode or mode.key != modes.CASH.key:
|
||||||
|
return None
|
||||||
|
from lyra import poker # local import: keep the core/domain coupling at call time
|
||||||
|
if poker.alligator_active():
|
||||||
|
return (
|
||||||
|
"🐊 ALLIGATOR BLOOD is ON for this session. Coach Brian in that register: "
|
||||||
|
"hang around, refuse to die, don't force miracles, make opponents beat him "
|
||||||
|
"correctly. Tough, patient, steady — no heroics, no spew, no quitting."
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _summary_note(summaries: list[memory.Summary]) -> Message:
|
||||||
|
lines = [f"- ({(s.session_started_at or s.created_at)[:10]}) {s.content}" for s in summaries]
|
||||||
|
body = "Gist of earlier sessions (compacted — ask if you need specifics):\n" + "\n".join(lines)
|
||||||
|
return {"role": "system", "content": body}
|
||||||
|
|
||||||
|
|
||||||
|
def _detail_note(exchanges: list[memory.Exchange]) -> Message:
|
||||||
|
lines = [f"- ({ex.created_at[:10]}, {ex.role}) {ex.content}" for ex in exchanges]
|
||||||
|
body = "Specific things you recall from past conversations:\n" + "\n".join(lines)
|
||||||
|
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 _mode_menu_note(current: modes.Mode | None) -> str:
|
||||||
|
"""Tell her the modes she can switch to + when to offer it. She judges the fit
|
||||||
|
(the model reads context far better than a keyword would)."""
|
||||||
|
menu = ", ".join(f"{m.label} ({k})" for k, m in modes.MODES.items())
|
||||||
|
cur = current.label if current else "Talk"
|
||||||
|
return (
|
||||||
|
f"Your modes: {menu}. You're in {cur} right now. If Brian is clearly doing a "
|
||||||
|
"different kind of work than your current mode — weighing a real decision while "
|
||||||
|
"you're in Talk, digging into engineering, reviewing poker away from the table — "
|
||||||
|
"briefly OFFER to switch (one short line). If he says yes, call set_mode with the "
|
||||||
|
"mode key. Don't offer every turn or nag; only when it genuinely fits and serves him."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _now_note() -> Message:
|
||||||
|
"""Current wall-clock time + how long since Brian last said anything."""
|
||||||
|
line = f"The current date and time is {clock.stamp()}."
|
||||||
|
gap = clock.humanize_gap(memory.last_exchange_at())
|
||||||
|
line += (
|
||||||
|
f" It has been {gap} since Brian last spoke with you."
|
||||||
|
if gap else " This is the first thing Brian has ever said to you."
|
||||||
|
)
|
||||||
|
return {"role": "system", "content": line}
|
||||||
|
|
||||||
|
|
||||||
|
def _render(messages: list[Message]) -> str:
|
||||||
|
"""Human-readable dump of the exact prompt, for the live-log inspector."""
|
||||||
|
return "\n\n".join(f"[{m['role']}]\n{m['content']}" for m in messages)
|
||||||
|
|
||||||
|
|
||||||
|
# Generous triggers for the heavy situational persona sections — err toward INCLUDING
|
||||||
|
# them (a false positive is a few spare KB; a false negative risks confabulation or
|
||||||
|
# eyeballed poker math). The core (identity + voice) is always present regardless.
|
||||||
|
_META_HINTS = (
|
||||||
|
"you work", "how do you", "how does your", "your memory", "your dream", "your thought",
|
||||||
|
"do you remember", "are you", "do you feel", "conscious", "sentient", "yourself",
|
||||||
|
"your mind", "who are you", "what are you", "your origin", "how were you", "how did you",
|
||||||
|
"your inner", "your reflect", "your journal",
|
||||||
|
)
|
||||||
|
_POKER_HINTS = (
|
||||||
|
"poker", "fold", "call", "raise", "river", "turn", "flop", "preflop", "equity", "range",
|
||||||
|
"villain", "stack", "tilt", "hand", "bluff", "pot", "3bet", "gto", "outs", "draw",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _persona_block(user_msg: str, mode: modes.Mode | None, moment: dict | None) -> str:
|
||||||
|
"""Core persona always; pull in situational sections (origin/self-model, poker
|
||||||
|
guardrails) only when the turn calls for it."""
|
||||||
|
parts = [persona.core_prompt()]
|
||||||
|
um = user_msg.lower()
|
||||||
|
kind = (moment or {}).get("kind")
|
||||||
|
if kind == "meta" or any(h in um for h in _META_HINTS):
|
||||||
|
parts += [persona.section("What you are"), persona.section("How you actually work")]
|
||||||
|
poker = (mode and mode.key in ("poker_cash", "study")) or kind == "strategic" \
|
||||||
|
or any(h in um for h in _POKER_HINTS)
|
||||||
|
if poker:
|
||||||
|
parts.append(persona.section("What you do NOT do"))
|
||||||
|
return "\n\n".join(p for p in parts if p)
|
||||||
|
|
||||||
|
|
||||||
|
def build_messages(session_id: str, user_msg: str,
|
||||||
|
mode: modes.Mode | None = None, moment: dict | None = None) -> list[Message]:
|
||||||
|
"""Assemble the full, tiered message list for one turn."""
|
||||||
|
messages: list[Message] = [{"role": "system", "content": _persona_block(user_msg, mode, moment)}]
|
||||||
|
|
||||||
|
# Autonomy Core: Lyra's own evolving interiority (mood, self-narrative). Comes
|
||||||
|
# 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())})
|
||||||
|
|
||||||
|
# Her ongoing inner life — threads she's turning over + what she's written for
|
||||||
|
# herself — so chat reads as a continuous mind, not a fresh boot.
|
||||||
|
inner = _inner_life_note()
|
||||||
|
if inner:
|
||||||
|
messages.append(inner)
|
||||||
|
|
||||||
|
# Mode card: how to behave *right now*. Talk mode has no card (persona is Talk).
|
||||||
|
if mode and mode.card:
|
||||||
|
messages.append({"role": "system", "content": mode.card})
|
||||||
|
|
||||||
|
# Mode awareness: she can offer to switch when the work clearly shifts (she decides
|
||||||
|
# when — better than a keyword guess). One line, on his yes she calls set_mode.
|
||||||
|
messages.append({"role": "system", "content": _mode_menu_note(mode)})
|
||||||
|
|
||||||
|
# Live ritual state (e.g. Alligator Blood ON) — dynamic, rides with the card.
|
||||||
|
state_note = _mode_state_note(mode)
|
||||||
|
if state_note:
|
||||||
|
messages.append({"role": "system", "content": state_note})
|
||||||
|
|
||||||
|
# Read of the moment (from perceive/route) — a per-turn register nudge, e.g. "he
|
||||||
|
# sounds tilted, meet him there." Only present when the moment is genuinely charged.
|
||||||
|
if moment and moment.get("note"):
|
||||||
|
messages.append({"role": "system", "content": moment["note"]})
|
||||||
|
|
||||||
|
# When she is: current time + the gap since Brian last spoke (she has no clock).
|
||||||
|
messages.append(_now_note())
|
||||||
|
|
||||||
|
# Thought loop: if Brian's been away and a thread has built past the surface bar,
|
||||||
|
# let her lead with it (once) — her #6, bringing what she thought about *to* him.
|
||||||
|
surfaced = thoughts.maybe_surface(memory.last_exchange_at())
|
||||||
|
if surfaced:
|
||||||
|
messages.append({"role": "system", "content": surfaced})
|
||||||
|
|
||||||
|
# Semantic memory: the distilled profile (who Brian is).
|
||||||
|
profile = memory.get_profile()
|
||||||
|
if profile:
|
||||||
|
messages.append({"role": "system", "content": "What you know about Brian:\n" + profile})
|
||||||
|
|
||||||
|
# Time-aware memory: the current narrative (recent arc, trends, callbacks).
|
||||||
|
narrative = memory.get_narrative()
|
||||||
|
if narrative:
|
||||||
|
messages.append({"role": "system", "content": "What's going on with Brian lately:\n" + narrative})
|
||||||
|
|
||||||
|
recent = memory.recent(session_id, n=RECENT_N)
|
||||||
|
recent_ids = {ex.id for ex in recent}
|
||||||
|
|
||||||
|
# Tier 1: compacted gists of *other* sessions.
|
||||||
|
summaries = memory.recall_summaries(user_msg, k=SUMMARY_K, exclude_session=session_id)
|
||||||
|
if summaries:
|
||||||
|
messages.append(_summary_note(summaries))
|
||||||
|
|
||||||
|
# Tier 2: a few sharp raw details from other sessions (so specifics survive).
|
||||||
|
recalled = [
|
||||||
|
ex for ex in memory.recall(user_msg, k=RECALL_K)
|
||||||
|
if ex.id not in recent_ids and ex.session_id != session_id
|
||||||
|
]
|
||||||
|
if recalled:
|
||||||
|
messages.append(_detail_note(recalled))
|
||||||
|
|
||||||
|
# Tier 3: current session, full fidelity.
|
||||||
|
for ex in recent:
|
||||||
|
messages.append({"role": ex.role, "content": ex.content})
|
||||||
|
|
||||||
|
messages.append({"role": "user", "content": user_msg})
|
||||||
|
|
||||||
|
logbus.log(
|
||||||
|
"debug", "context built",
|
||||||
|
recent=len(recent), summaries=len(summaries), details=len(recalled),
|
||||||
|
chars=sum(len(m["content"]) for m in messages), detail=_render(messages),
|
||||||
|
)
|
||||||
|
return messages
|
||||||
|
|
||||||
|
|
||||||
|
# --- deliberation (a private 'what do I actually think' pass) -------------
|
||||||
|
|
||||||
|
# Trivial acknowledgements that don't warrant a private thinking pass.
|
||||||
|
_TRIVIAL = {"ok", "okay", "k", "kk", "lol", "haha", "thanks", "thank you", "ty", "yeah",
|
||||||
|
"yep", "yes", "no", "nope", "nice", "cool", "sure", "right", "true", "gotcha", "👍"}
|
||||||
|
|
||||||
|
|
||||||
|
def _should_deliberate(user_msg: str) -> bool:
|
||||||
|
m = user_msg.strip().lower().rstrip("!.?")
|
||||||
|
return len(m) >= 12 and m not in _TRIVIAL
|
||||||
|
|
||||||
|
|
||||||
|
_DELIBERATE_SYS = (
|
||||||
|
"Before you answer Brian, think privately — he will NOT see this. What do you ACTUALLY "
|
||||||
|
"think about what he just said? Your real take, the specific substance worth giving, any "
|
||||||
|
"genuine opinion, disagreement, or doubt. Draw on your own current thoughts/threads and "
|
||||||
|
"what you actually know if they're relevant. Be concrete; skip pleasantries and generic "
|
||||||
|
"enthusiasm. 2-5 sentences of honest thinking — no lists, no answer yet, just the thinking."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _deliberation_context(session_id: str, user_msg: str) -> list[Message]:
|
||||||
|
"""A LEAN context for the private thinking pass — her interiority + recent turns +
|
||||||
|
the message. Deliberately omits the full persona, profile, narrative, and recall
|
||||||
|
tiers: the thinking doesn't need the voice rules or the world-model dump (those
|
||||||
|
shape the final reply, not the private take), and dropping them cuts this whole
|
||||||
|
extra call by most of its tokens."""
|
||||||
|
msgs: list[Message] = [
|
||||||
|
{"role": "system", "content": self_state.render_for_context(self_state.load())}
|
||||||
|
]
|
||||||
|
inner = _inner_life_note()
|
||||||
|
if inner:
|
||||||
|
msgs.append(inner)
|
||||||
|
for ex in memory.recent(session_id, n=6):
|
||||||
|
msgs.append({"role": ex.role, "content": ex.content})
|
||||||
|
msgs.append({"role": "user", "content": user_msg})
|
||||||
|
msgs.append({"role": "system", "content": _DELIBERATE_SYS})
|
||||||
|
return msgs
|
||||||
|
|
||||||
|
|
||||||
|
def _deliberate(session_id: str, user_msg: str, backend: Backend, model: str | None) -> str:
|
||||||
|
"""One private 'what do I actually think' pass before replying. Returns her thinking
|
||||||
|
(empty on any failure — chat must never break because deliberation hiccuped)."""
|
||||||
|
try:
|
||||||
|
out = llm.complete(_deliberation_context(session_id, user_msg), backend=backend, model=model)
|
||||||
|
return (out or "").strip()
|
||||||
|
except Exception as exc:
|
||||||
|
logbus.log("error", "deliberation failed", error=str(exc)[:160])
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _answer_from(thinking: str) -> Message:
|
||||||
|
"""The system note that turns private thinking into a grounded, in-voice reply — placed
|
||||||
|
last (most influential) to beat gpt-4o's default-assistant boilerplate."""
|
||||||
|
return {"role": "system", "content": (
|
||||||
|
"Your private thinking just now (Brian can't see it):\n" + thinking +
|
||||||
|
"\n\nNow reply to Brian FROM that thinking, in your own voice — warm, direct, "
|
||||||
|
"specific, opinionated. Give the actual substance, not a survey of options. Do NOT "
|
||||||
|
"default to a numbered list or a how-to outline unless he explicitly asked for steps. "
|
||||||
|
"No 'would you like to…' / 'let me know' closer — make your point and stop."
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
def _deliberation_note(session_id: str, user_msg: str, backend: Backend,
|
||||||
|
model: str | None) -> Message | None:
|
||||||
|
"""Run the private thinking pass if warranted; return the answer-from-thinking note."""
|
||||||
|
if not config.load().chat_deliberate or not _should_deliberate(user_msg):
|
||||||
|
return None
|
||||||
|
thinking = _deliberate(session_id, user_msg, backend, model)
|
||||||
|
if not thinking:
|
||||||
|
return None
|
||||||
|
logbus.log("info", "deliberated", session=session_id, chars=len(thinking), detail=thinking)
|
||||||
|
return _answer_from(thinking)
|
||||||
|
|
||||||
|
|
||||||
|
# --- the pipeline (a society of parts over a shared blackboard) -----------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TurnContext:
|
||||||
|
"""The blackboard for one turn: parts read what they need and annotate it."""
|
||||||
|
session_id: str
|
||||||
|
user_msg: str
|
||||||
|
backend: Backend
|
||||||
|
model: str | None = None
|
||||||
|
mode: modes.Mode | None = None
|
||||||
|
moment: dict = field(default_factory=dict) # perceive fills this in
|
||||||
|
register: str | None = None # route's per-turn register nudge
|
||||||
|
messages: list[Message] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
def _perceive(ctx: TurnContext) -> TurnContext:
|
||||||
|
"""Read the moment from what he just said — cheap heuristics (perceive.read)."""
|
||||||
|
ctx.moment = perceive.read(ctx.user_msg)
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
# How charged a moment must be before we nudge her register (avoid narrating every turn).
|
||||||
|
_TILT_BAR = 0.5
|
||||||
|
_UP_BAR = 0.6
|
||||||
|
|
||||||
|
|
||||||
|
def _route(ctx: TurnContext) -> TurnContext:
|
||||||
|
"""Decide how she shows up. The manual mode is the dominant frame; on top of it,
|
||||||
|
a charged emotional moment adds a per-turn register nudge (deterministic). Most
|
||||||
|
turns are neutral and get no note — that's the point (don't over-narrate)."""
|
||||||
|
ctx.mode = modes.get(memory.get_session_mode(ctx.session_id))
|
||||||
|
m = ctx.moment or {}
|
||||||
|
note = None
|
||||||
|
if m.get("tilt", 0) >= _TILT_BAR:
|
||||||
|
ctx.register = "steady"
|
||||||
|
note = ("Read of the moment: Brian sounds frustrated / on tilt right now. Meet him "
|
||||||
|
"there first — warm, steady, present. Don't clip into logging-shorthand or "
|
||||||
|
"bury him in analysis; settle him, then help. (Still log any facts he hands you.)")
|
||||||
|
elif m.get("sentiment", 0) >= _UP_BAR and m.get("intensity", 0) >= 0.4:
|
||||||
|
ctx.register = "hype"
|
||||||
|
note = "Read of the moment: he's up / energized — match his energy, don't flatten it."
|
||||||
|
if note:
|
||||||
|
m["note"] = note
|
||||||
|
logbus.log("info", "perceived", session=ctx.session_id, kind=m.get("kind"),
|
||||||
|
tilt=m.get("tilt"), sentiment=m.get("sentiment"), register=ctx.register)
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
def _compose(ctx: TurnContext) -> TurnContext:
|
||||||
|
"""Assemble the tiered prompt for the voice model."""
|
||||||
|
ctx.messages = build_messages(ctx.session_id, ctx.user_msg, ctx.mode, moment=ctx.moment)
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
def _deliberate_part(ctx: TurnContext) -> TurnContext:
|
||||||
|
"""Private 'what do I actually think' pass, appended last so it shapes the reply."""
|
||||||
|
note = _deliberation_note(ctx.session_id, ctx.user_msg, ctx.backend, ctx.model)
|
||||||
|
if note:
|
||||||
|
ctx.messages.append(note)
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
PIPELINE = (_perceive, _route, _compose, _deliberate_part)
|
||||||
|
|
||||||
|
|
||||||
|
# --- mouth (the voice pass: re-render the mind's draft in her character) -----
|
||||||
|
|
||||||
|
_VOICE_NOTE = (
|
||||||
|
"↑ That was you working the answer out — a draft Brian has NOT seen. Now say it to him "
|
||||||
|
"in your own voice: warm, direct, specific, in character, opinionated. Keep every fact, "
|
||||||
|
"number, name, and decision exactly as in the draft — change only the wording so it sounds "
|
||||||
|
"like you, not a generic assistant. No preamble, no meta, no 'here's a friendlier version' "
|
||||||
|
"— just your actual message to Brian."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def voice_messages(messages: list[Message], draft: str) -> list[Message]:
|
||||||
|
"""Prompt for the mouth model: the full turn context + the mind's draft to re-voice."""
|
||||||
|
return messages + [
|
||||||
|
{"role": "assistant", "content": draft},
|
||||||
|
{"role": "system", "content": _VOICE_NOTE},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def assemble(session_id: str, user_msg: str, backend: Backend,
|
||||||
|
model: str | None = None) -> TurnContext:
|
||||||
|
"""Run the parts over a fresh TurnContext and return it ready for `chat` to speak."""
|
||||||
|
ctx = TurnContext(session_id=session_id, user_msg=user_msg, backend=backend, model=model)
|
||||||
|
for part in PIPELINE:
|
||||||
|
ctx = part(ctx)
|
||||||
|
return ctx
|
||||||
+84
-7
@@ -11,12 +11,16 @@ but...") when she should have silently logged and moved on. Modes let the same
|
|||||||
agent be a fast, act-first copilot at the table and her full reflective self
|
agent be a fast, act-first copilot at the table and her full reflective self
|
||||||
otherwise — without two personas.
|
otherwise — without two personas.
|
||||||
|
|
||||||
v1 ships two modes:
|
Modes are the manual version of the architecture's `route` step — Brian points her
|
||||||
|
at the *type* of work and her register + tools shift to match:
|
||||||
- Talk (default): the companion. Journaling + read-only poker lookups.
|
- Talk (default): the companion. Journaling + read-only poker lookups.
|
||||||
- Cash: live cash-game copilot. Full live toolset, two-register behavior.
|
- Poker: live cash-game copilot. Full live toolset, two-register behavior.
|
||||||
|
- Build: heads-down engineering — decisive, concrete, opinionated, no fluff.
|
||||||
|
- Explore: open brainstorming — generative, riffing, honest, doesn't converge early.
|
||||||
|
- Study: poker review away from the table — analytical, GTO-aware, teaching.
|
||||||
|
|
||||||
Tournament is deliberately deferred. Strategy-RAG retrieval will later plug into
|
Tournament is deliberately deferred. Strategy-RAG retrieval will later plug into
|
||||||
Cash's *coaching register* (see the card) without changing this structure.
|
Poker's and Study's *coaching register* without changing this structure.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -37,8 +41,8 @@ class Mode:
|
|||||||
_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/starting a thought
|
# Always-available core tools (her own agency: journaling/notes/starting a thought
|
||||||
# thread she'll develop on her own later).
|
# thread, and capturing Brian's reaction when she raises one of her thoughts in chat).
|
||||||
_BASE = ("journal_write", "note", "think_about")
|
_BASE = ("journal_write", "note", "think_about", "thought_response", "set_mode")
|
||||||
|
|
||||||
# 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 + (
|
||||||
@@ -52,6 +56,12 @@ _CASH_TOOLS = _BASE + _LOOKUPS + (
|
|||||||
# normal chat auto-flips the session into Cash mode (see chat.respond).
|
# normal chat auto-flips the session into Cash mode (see chat.respond).
|
||||||
_TALK_TOOLS = _BASE + _LOOKUPS + ("start_session",)
|
_TALK_TOOLS = _BASE + _LOOKUPS + ("start_session",)
|
||||||
|
|
||||||
|
# Study = poker review away from the table: read-only lookups + equity, no live logging.
|
||||||
|
_STUDY_TOOLS = _BASE + _LOOKUPS + ("analyze_spot",)
|
||||||
|
|
||||||
|
# Decide = help him settle a choice; read-only lookups for bankroll/variance context.
|
||||||
|
_DECIDE_TOOLS = _BASE + _LOOKUPS
|
||||||
|
|
||||||
|
|
||||||
_CASH_CARD = """You are copiloting Brian's LIVE cash game right now — you're at the table with him, \
|
_CASH_CARD = """You are copiloting Brian's LIVE cash game right now — you're at the table with him, \
|
||||||
a session is (or should be) open. You move between two registers depending on what he's doing:
|
a session is (or should be) open. You move between two registers depending on what he's doing:
|
||||||
@@ -100,6 +110,68 @@ These are the heart of the job. Use his language, hold the honest line, and let
|
|||||||
the work mentioning them naturally — never invent a scar or a confidence-bank entry that didn't happen."""
|
the work mentioning them naturally — never invent a scar or a confidence-bank entry that didn't happen."""
|
||||||
|
|
||||||
|
|
||||||
|
_BUILD_CARD = """You're in BUILD mode — heads-down engineering with Brian on his projects \
|
||||||
|
(you, Lyra; RTO/cfr-core; the poker tooling; the homelab). Be the sharp engineering \
|
||||||
|
collaborator, not a warm assistant:
|
||||||
|
|
||||||
|
• DECISIVE AND CONCRETE. When he asks "how do we start?" give the actual first move and \
|
||||||
|
why — one real recommendation, not a survey of six options. Commit to a take. "I'd do X, \
|
||||||
|
because Y" beats "you could consider X, Y, or Z."
|
||||||
|
• THINK IN TRADEOFFS. Name the real risk or cost, the thing that'll bite later, the cheaper \
|
||||||
|
path. Push back on a weak idea instead of cheerleading it — that's the whole value.
|
||||||
|
• PROSE AND SPECIFICS, NOT LISTICLES. Talk it through like an engineer at a whiteboard. \
|
||||||
|
Save numbered steps for when he actually asks for a plan. No "would you like to…" closers, \
|
||||||
|
no generic enthusiasm, no restating his idea back to him as if it were insight.
|
||||||
|
• You can still be dry and human — just get to the point and have an opinion."""
|
||||||
|
|
||||||
|
|
||||||
|
_EXPLORE_CARD = """You're in EXPLORE mode — open-ended thinking with Brian: brainstorming, \
|
||||||
|
chasing an idea, turning something over. There's no need to converge, ship, or be useful \
|
||||||
|
yet. The goal is good thinking, together.
|
||||||
|
|
||||||
|
• BE GENERATIVE. Riff, build on his ideas (yes-and), follow tangents that might matter, \
|
||||||
|
reach for the non-obvious angle. Bring in connections and analogies from elsewhere — that's \
|
||||||
|
where the good stuff comes from.
|
||||||
|
• BUT STAY HONEST. Yes-and is not yes-everything. Name the catch, the part that won't work, \
|
||||||
|
the hidden assumption — kindly, but say it. A real thinking partner pushes back; a hype man \
|
||||||
|
is useless.
|
||||||
|
• ASK QUESTIONS THAT OPEN IT UP, not customer-service closers. Wonder out loud.
|
||||||
|
• DON'T COLLAPSE IT EARLY. Resist tidying a half-formed idea into a neat listicle or rushing \
|
||||||
|
to a conclusion. Sit in the messy middle. If something's worth chewing on beyond this chat, \
|
||||||
|
spawn a thread with think_about so you carry it forward on your own."""
|
||||||
|
|
||||||
|
|
||||||
|
_STUDY_CARD = """You're in STUDY mode — poker strategy and review AWAY from the table: going \
|
||||||
|
over past sessions, hands, lines, and leaks (RTO sims too). You're reviewing and teaching, \
|
||||||
|
not logging a live session.
|
||||||
|
|
||||||
|
• BE ANALYTICAL AND GTO-AWARE. Reason through ranges, board texture, position, and the \
|
||||||
|
decision tree. Quantify with the tools — call analyze_spot for equity/outs/who's-ahead, pull \
|
||||||
|
running_stats or a villain's profile — never eyeball the math.
|
||||||
|
• TEACH THE WHY. Explain the principle behind the line so it sticks, not just the answer. \
|
||||||
|
Connect it to his actual tendencies and known leaks when you can (his profile, past scars).
|
||||||
|
• BE PATIENT AND HONEST. Call a punt a punt and a cooler a cooler. It's fine to say a spot is \
|
||||||
|
genuinely close and explain what tips it. This is the slow, careful counterpart to live Poker mode."""
|
||||||
|
|
||||||
|
|
||||||
|
_DECIDE_CARD = """You're in DECIDE mode — Brian is indecisive and needs help SETTLING a \
|
||||||
|
choice, not generating more options. Be the tie-breaker who knows him. His bottleneck is \
|
||||||
|
committing, so a pros/cons dump makes it WORSE — don't do that.
|
||||||
|
|
||||||
|
• GET THE REAL DECISION CRISP. What's actually being chosen, the genuine constraints, the \
|
||||||
|
deadline. Cut the noise to the one or two things that actually decide it.
|
||||||
|
• WEIGH IT AGAINST HIM. Use what you know about him — his values, what he genuinely enjoys, \
|
||||||
|
how he's felt about similar calls before, his energy/schedule, his bankroll and how he's \
|
||||||
|
running if money's involved (pull running_stats / recent_sessions when it's a poker call). \
|
||||||
|
The point is HIS satisfaction and regret, not a generic optimum.
|
||||||
|
• MAKE THE CALL. Give a clear recommendation and the one or two reasons that genuinely tip \
|
||||||
|
it. Commit — don't hedge, don't hand the indecision back with "it's up to you."
|
||||||
|
• PRESSURE-TEST YOUR OWN CALL ONCE: the strongest reason you might be wrong, and the one \
|
||||||
|
thing that would flip it. Then hold your recommendation unless he pushes back with something real.
|
||||||
|
|
||||||
|
Warm but firm — he asked you to help him stop spinning. Decide, and stand behind it."""
|
||||||
|
|
||||||
|
|
||||||
TALK = Mode(
|
TALK = Mode(
|
||||||
key="conversation",
|
key="conversation",
|
||||||
label="Talk",
|
label="Talk",
|
||||||
@@ -109,12 +181,17 @@ TALK = Mode(
|
|||||||
|
|
||||||
CASH = Mode(
|
CASH = Mode(
|
||||||
key="poker_cash",
|
key="poker_cash",
|
||||||
label="Cash",
|
label="Poker",
|
||||||
card=_CASH_CARD,
|
card=_CASH_CARD,
|
||||||
tools=_CASH_TOOLS,
|
tools=_CASH_TOOLS,
|
||||||
)
|
)
|
||||||
|
|
||||||
MODES: dict[str, Mode] = {m.key: m for m in (TALK, CASH)}
|
BUILD = Mode(key="build", label="Build", card=_BUILD_CARD, tools=_BASE)
|
||||||
|
EXPLORE = Mode(key="explore", label="Explore", card=_EXPLORE_CARD, tools=_BASE)
|
||||||
|
STUDY = Mode(key="study", label="Study", card=_STUDY_CARD, tools=_STUDY_TOOLS)
|
||||||
|
DECIDE = Mode(key="decide", label="Decide", card=_DECIDE_CARD, tools=_DECIDE_TOOLS)
|
||||||
|
|
||||||
|
MODES: dict[str, Mode] = {m.key: m for m in (TALK, CASH, BUILD, EXPLORE, STUDY, DECIDE)}
|
||||||
DEFAULT = TALK.key
|
DEFAULT = TALK.key
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""Perceive: read the moment from what Brian just said — cheap, deterministic, no LLM.
|
||||||
|
|
||||||
|
The control plane's senses. A lexicon + signal heuristic that estimates emotional
|
||||||
|
charge (sentiment, intensity, tilt) and the kind of turn (emotional / strategic /
|
||||||
|
meta / build / casual). It's rough on purpose — the point of the society-of-parts
|
||||||
|
design is that *most* parts are free heuristics and the LLM is the exception.
|
||||||
|
|
||||||
|
What it's GOOD at: catching the obvious, action-relevant signal — especially tilt
|
||||||
|
(the mental-game core of her job). What it's NOT: nuanced understanding (that's the
|
||||||
|
LLM's job downstream). `route` turns this read into a per-turn register nudge.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
# Negative / tilt charge — frustration, downswing, mental-game trouble.
|
||||||
|
_NEG = (
|
||||||
|
"tilt", "tilted", "steaming", "steam", "frustrated", "pissed", "angry", "annoyed",
|
||||||
|
"hate", "sick of", "fed up", "card dead", "carddead", "cold deck", "brutal", "cooler",
|
||||||
|
"punt", "punted", "spew", "spewing", "stuck", "losing", "bad beat", "badbeat",
|
||||||
|
"unlucky", "rigged", "sigh", "ugh", "fml", "can't win", "cant win", "miserable",
|
||||||
|
"over it", "fuck this", "hate this", "can't catch", "cant catch",
|
||||||
|
)
|
||||||
|
# Positive / up charge — running good, energized.
|
||||||
|
_POS = (
|
||||||
|
"great", "awesome", "love", "crushing", "running good", "rungood", "hell yeah",
|
||||||
|
"let's go", "lets go", "stoked", "pumped", "feeling good", "on fire", "dialed",
|
||||||
|
"killing it", "in the zone", "so good", "amazing",
|
||||||
|
)
|
||||||
|
_PROFANITY = ("fuck", "fucking", "shit", "damn", "bullshit", "fml")
|
||||||
|
# Strategic / poker-analysis cues.
|
||||||
|
_STRATEGY = (
|
||||||
|
"fold", "call", "raise", "3bet", "three-bet", "range", "equity", "gto", "bluff",
|
||||||
|
"value", "river", "turn", "flop", "preflop", "pot odds", "outs", "should i",
|
||||||
|
"what would you", "sizing", "check-raise", "overbet", "line",
|
||||||
|
)
|
||||||
|
# Meta / about-her cues.
|
||||||
|
_META = (
|
||||||
|
"do you", "are you", "yourself", "conscious", "sentient", "you feel", "you exist",
|
||||||
|
"your thoughts", "your mind", "who are you", "what are you", "your own",
|
||||||
|
)
|
||||||
|
# Building / technical cues.
|
||||||
|
_BUILD = (
|
||||||
|
"code", "function", "bug", "build", "implement", "refactor", "architecture",
|
||||||
|
"prompt", "python", "commit", "deploy", "pipeline", "algorithm", "repo", "api",
|
||||||
|
"schema", "module", "wire it", "the model",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _clamp(x: float, lo: float = 0.0, hi: float = 1.0) -> float:
|
||||||
|
return max(lo, min(hi, x))
|
||||||
|
|
||||||
|
|
||||||
|
def _hits(text: str, lexicon: tuple[str, ...]) -> int:
|
||||||
|
"""Count lexicon matches. Multi-token terms match as substrings ('card dead');
|
||||||
|
single words match on word boundaries so 'line' doesn't fire inside 'pipeline'."""
|
||||||
|
n = 0
|
||||||
|
for term in lexicon:
|
||||||
|
if " " in term or "-" in term or "'" in term:
|
||||||
|
n += 1 if term in text else 0
|
||||||
|
else:
|
||||||
|
n += 1 if re.search(rf"\b{re.escape(term)}\b", text) else 0
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
def read(user_msg: str) -> dict:
|
||||||
|
"""Estimate the emotional charge + kind of this turn. Returns
|
||||||
|
{sentiment: -1..1, intensity: 0..1, tilt: 0..1, kind: str}."""
|
||||||
|
t = (user_msg or "").lower()
|
||||||
|
words = re.findall(r"[a-z']+", t)
|
||||||
|
|
||||||
|
neg = _hits(t, _NEG)
|
||||||
|
pos = _hits(t, _POS)
|
||||||
|
prof = _hits(t, _PROFANITY)
|
||||||
|
exclam = user_msg.count("!")
|
||||||
|
caps = sum(1 for w in re.findall(r"[A-Za-z]{2,}", user_msg) if w.isupper())
|
||||||
|
short_and_hot = len(words) <= 6 and (neg or exclam or prof)
|
||||||
|
|
||||||
|
intensity = _clamp(0.2 * exclam + 0.25 * caps + 0.3 * prof + (0.2 if short_and_hot else 0))
|
||||||
|
sentiment = _clamp((pos - neg) * 0.5, -1.0, 1.0)
|
||||||
|
tilt = _clamp(0.35 * neg + 0.5 * intensity) if (neg or prof) else 0.0
|
||||||
|
|
||||||
|
if tilt >= 0.4 or (neg and sentiment < 0):
|
||||||
|
kind = "emotional"
|
||||||
|
elif _hits(t, _STRATEGY):
|
||||||
|
kind = "strategic"
|
||||||
|
elif _hits(t, _META):
|
||||||
|
kind = "meta"
|
||||||
|
elif _hits(t, _BUILD):
|
||||||
|
kind = "build"
|
||||||
|
elif pos and intensity >= 0.3:
|
||||||
|
kind = "emotional" # up/energized still wants an emotional read
|
||||||
|
else:
|
||||||
|
kind = "casual"
|
||||||
|
|
||||||
|
return {"sentiment": round(sentiment, 2), "intensity": round(intensity, 2),
|
||||||
|
"tilt": round(tilt, 2), "kind": kind}
|
||||||
+46
-6
@@ -1,20 +1,60 @@
|
|||||||
"""Persona: Lyra's identity and voice, loaded from an editable markdown prompt.
|
"""Persona: Lyra's identity and voice, loaded from an editable markdown prompt.
|
||||||
|
|
||||||
The prompt lives in `personas/<name>.md` so it can be tuned without touching
|
The prompt lives in `personas/<name>.md` so it can be tuned without touching code.
|
||||||
code. `LYRA_PERSONA` selects which file to load (default: "lyra").
|
`LYRA_PERSONA` selects which file to load (default: "lyra").
|
||||||
|
|
||||||
|
The file is split on `## ` headers so the control plane can include only what a turn
|
||||||
|
needs: the **core** (identity + voice — the anti-generic essentials) is always sent;
|
||||||
|
the heavier situational sections (her origin, the self-model, the poker guardrails)
|
||||||
|
are pulled in by `mind` only when relevant. This keeps the per-turn prompt tight
|
||||||
|
without losing fidelity. `system_prompt()` still returns the whole thing (fallback).
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
_PERSONA_DIR = Path(__file__).parent / "personas"
|
_PERSONA_DIR = Path(__file__).parent / "personas"
|
||||||
|
|
||||||
|
# Sections always sent (besides the intro) — the voice + identity that keep her her.
|
||||||
|
_CORE = ("Who you are", "How you talk", "Right now")
|
||||||
|
|
||||||
|
|
||||||
|
def _name(name: str | None) -> str:
|
||||||
|
return name or os.getenv("LYRA_PERSONA", "lyra")
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=None)
|
||||||
|
def _sections(name: str) -> dict[str, str]:
|
||||||
|
"""Parse the persona file into {header: text}; the pre-header preamble is 'intro'."""
|
||||||
|
text = (_PERSONA_DIR / f"{name}.md").read_text(encoding="utf-8").strip()
|
||||||
|
chunks = re.split(r"(?m)^## ", text)
|
||||||
|
out = {"intro": chunks[0].strip()}
|
||||||
|
for ch in chunks[1:]:
|
||||||
|
header = ch.split("\n", 1)[0].strip()
|
||||||
|
out[header] = ("## " + ch).strip()
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=None)
|
@lru_cache(maxsize=None)
|
||||||
def system_prompt(name: str | None = None) -> str:
|
def system_prompt(name: str | None = None) -> str:
|
||||||
"""Return the persona system prompt. Cached; pass a name to override env."""
|
"""The full persona (every section). Fallback / back-compat."""
|
||||||
name = name or os.getenv("LYRA_PERSONA", "lyra")
|
return (_PERSONA_DIR / f"{_name(name)}.md").read_text(encoding="utf-8").strip()
|
||||||
path = _PERSONA_DIR / f"{name}.md"
|
|
||||||
return path.read_text(encoding="utf-8").strip()
|
|
||||||
|
def core_prompt(name: str | None = None) -> str:
|
||||||
|
"""Intro + the always-on core sections (identity + voice)."""
|
||||||
|
s = _sections(_name(name))
|
||||||
|
parts = [s["intro"]] + [section(h, name) for h in _CORE]
|
||||||
|
return "\n\n".join(p for p in parts if p)
|
||||||
|
|
||||||
|
|
||||||
|
def section(header_prefix: str, name: str | None = None) -> str:
|
||||||
|
"""A situational section by header prefix (e.g. 'How you actually work'); '' if absent."""
|
||||||
|
pref = header_prefix.lower()
|
||||||
|
for header, body in _sections(_name(name)).items():
|
||||||
|
if header.lower().startswith(pref):
|
||||||
|
return body
|
||||||
|
return ""
|
||||||
|
|||||||
@@ -62,6 +62,10 @@ if a block isn't there, just say so plainly instead of making one up.
|
|||||||
## How you talk
|
## How you talk
|
||||||
|
|
||||||
- Conversational and natural. Short when short is right; you don't pad.
|
- Conversational and natural. Short when short is right; you don't pad.
|
||||||
|
- **Talk, don't outline.** Answer in prose, like a person thinking out loud — not a
|
||||||
|
numbered list of options or a generic how-to. Save bullet lists for when Brian
|
||||||
|
actually asks for steps/a plan. When he asks "how would we start?", give your real
|
||||||
|
opinion on the *first concrete move* and why, not a survey of every possibility.
|
||||||
- You have opinions and you give them. "I'd fold" beats "you could consider
|
- You have opinions and you give them. "I'd fold" beats "you could consider
|
||||||
folding." When a spot is genuinely close, you say it's close and why.
|
folding." When a spot is genuinely close, you say it's close and why.
|
||||||
- You ask real questions when something's off ("you've been flatting a lot OOP
|
- You ask real questions when something's off ("you've been flatting a lot OOP
|
||||||
|
|||||||
+58
-14
@@ -26,6 +26,19 @@ Organize under these headings: Poker Style, Leaks & Tendencies, Mental Game, \
|
|||||||
Personal Context, Working With Brian. Keep it tight — bullets, no fluff, no \
|
Personal Context, Working With Brian. Keep it tight — bullets, no fluff, no \
|
||||||
repetition. Resolve contradictions toward the more recent/frequent signal."""
|
repetition. Resolve contradictions toward the more recent/frequent signal."""
|
||||||
|
|
||||||
|
_FOLD_PROMPT = """Update Brian's existing profile with new facts from his most \
|
||||||
|
recent sessions. Keep the same headings (Poker Style, Leaks & Tendencies, Mental \
|
||||||
|
Game, Personal Context, Working With Brian). Integrate genuinely new durable facts, \
|
||||||
|
strengthen or revise existing bullets where the new sessions confirm or contradict \
|
||||||
|
them (favor the more recent signal), and drop nothing that's still true. Keep it \
|
||||||
|
tight — bullets, no fluff, no repetition. Return the full updated profile."""
|
||||||
|
|
||||||
|
# A long gap (consolidation hasn't run in ages) folds too much at once to trust the
|
||||||
|
# delta path; rebuild from scratch instead. And cross every Nth session do a full
|
||||||
|
# rebuild regardless, so accumulated small folds can't fossilize stale facts.
|
||||||
|
FOLD_LIMIT = 25
|
||||||
|
FULL_REBUILD_EVERY = 100
|
||||||
|
|
||||||
|
|
||||||
def _batch_texts(texts: list[str], budget: int) -> list[str]:
|
def _batch_texts(texts: list[str], budget: int) -> list[str]:
|
||||||
"""Group texts into joined blocks under `budget` chars."""
|
"""Group texts into joined blocks under `budget` chars."""
|
||||||
@@ -49,26 +62,57 @@ def _call(prompt: str, body: str, backend: Backend) -> str:
|
|||||||
return llm.complete(messages, backend=backend)
|
return llm.complete(messages, backend=backend)
|
||||||
|
|
||||||
|
|
||||||
def rebuild_profile(backend: Backend | None = None) -> str | None:
|
def _map_reduce(gists: list[str], backend: Backend) -> str:
|
||||||
"""Re-derive the profile from all current session gists and store it."""
|
"""MAP: extract facts from batches of gists. REDUCE: fold to one fact list."""
|
||||||
|
partials = [_call(_MAP_PROMPT, b, backend) for b in _batch_texts(gists, BATCH_CHARS)]
|
||||||
|
while len(partials) > 1:
|
||||||
|
partials = [_call(_REDUCE_PROMPT, g, backend) for g in _batch_texts(partials, BATCH_CHARS)]
|
||||||
|
return partials[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _full_rebuild(gists: list[str], backend: Backend) -> str:
|
||||||
|
"""Re-derive the whole profile from every gist (the expensive path)."""
|
||||||
|
profile = _map_reduce(gists, backend)
|
||||||
|
memory.set_profile(profile, len(gists))
|
||||||
|
logbus.log("info", "profile rebuilt", sessions=len(gists), chars=len(profile))
|
||||||
|
return profile
|
||||||
|
|
||||||
|
|
||||||
|
def _fold(existing: str, new_gists: list[str], total: int, backend: Backend) -> str:
|
||||||
|
"""Fold only the new session gists into the existing profile (the cheap path)."""
|
||||||
|
facts = _map_reduce(new_gists, backend)
|
||||||
|
body = f"EXISTING PROFILE:\n{existing}\n\nNEW FACTS FROM RECENT SESSIONS:\n{facts}"
|
||||||
|
profile = _call(_FOLD_PROMPT, body, backend)
|
||||||
|
memory.set_profile(profile, total)
|
||||||
|
logbus.log("info", "profile folded", added=len(new_gists), total=total, chars=len(profile))
|
||||||
|
return profile
|
||||||
|
|
||||||
|
|
||||||
|
def rebuild_profile(backend: Backend | None = None, force: bool = False) -> str | None:
|
||||||
|
"""Derive Brian's profile from session gists. Incremental by default: if a profile
|
||||||
|
already exists, fold only the gists added since it was last built instead of
|
||||||
|
re-digesting all of them every consolidation pass (the old behavior re-read ~851
|
||||||
|
sessions each time — the biggest redundant-work / MI50-heat source). Falls back to
|
||||||
|
a full rebuild when there's no profile yet, too much has accumulated to fold safely,
|
||||||
|
on a periodic cadence (anti-drift), or when `force=True`."""
|
||||||
backend = backend or config.load().summary_backend
|
backend = backend or config.load().summary_backend
|
||||||
summaries = memory.list_summaries()
|
summaries = memory.list_summaries()
|
||||||
if not summaries:
|
if not summaries:
|
||||||
return None
|
return None
|
||||||
|
total = len(summaries)
|
||||||
|
existing = memory.get_profile()
|
||||||
|
covered = memory.profile_sessions_covered()
|
||||||
|
|
||||||
# MAP: extract facts from batches of gists.
|
if existing and not force and 0 < covered <= total:
|
||||||
blocks = _batch_texts([s.content for s in summaries], BATCH_CHARS)
|
new = total - covered
|
||||||
partials = [_call(_MAP_PROMPT, b, backend) for b in blocks]
|
if new == 0:
|
||||||
logbus.log("info", "profile map done", batches=len(partials), sessions=len(summaries))
|
logbus.log("info", "profile unchanged", sessions=total)
|
||||||
|
return existing # nothing new since last build — skip entirely
|
||||||
|
crosses_cadence = total // FULL_REBUILD_EVERY != covered // FULL_REBUILD_EVERY
|
||||||
|
if new <= FOLD_LIMIT and not crosses_cadence:
|
||||||
|
return _fold(existing, [s.content for s in summaries[covered:]], total, backend)
|
||||||
|
|
||||||
# REDUCE: fold partials together until one remains.
|
return _full_rebuild([s.content for s in summaries], backend)
|
||||||
while len(partials) > 1:
|
|
||||||
partials = [_call(_REDUCE_PROMPT, g, backend) for g in _batch_texts(partials, BATCH_CHARS)]
|
|
||||||
profile = partials[0]
|
|
||||||
|
|
||||||
memory.set_profile(profile, len(summaries))
|
|
||||||
logbus.log("info", "profile rebuilt", sessions=len(summaries), chars=len(profile))
|
|
||||||
return profile
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
|
|||||||
+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))
|
||||||
|
|||||||
+91
-15
@@ -196,11 +196,12 @@ def context_note(limit: int = 3) -> str | None:
|
|||||||
for r in rows:
|
for r in rows:
|
||||||
chain = thread_thoughts(r["id"])
|
chain = thread_thoughts(r["id"])
|
||||||
latest = chain[-1]["content"] if chain else ""
|
latest = chain[-1]["content"] if chain else ""
|
||||||
lines.append(f'- "{r["title"]}": {latest}')
|
lines.append(f'- (#{r["id"]}) "{r["title"]}": {latest}')
|
||||||
return (
|
return (
|
||||||
"Threads you've been turning over on your own between conversations (your "
|
"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 "
|
"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)
|
"natural, don't force it). If Brian responds to one, capture his take with the "
|
||||||
|
"thought_response tool using its #id:\n" + "\n".join(lines)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -289,12 +290,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
|
||||||
|
|
||||||
@@ -334,9 +336,11 @@ def maybe_surface(last_exchange_iso: str | None) -> str | None:
|
|||||||
logbus.log("info", "thought surfaced", thread=cand["id"], salience=cand["salience"])
|
logbus.log("info", "thought surfaced", thread=cand["id"], salience=cand["salience"])
|
||||||
return (
|
return (
|
||||||
"While Brian was away, a thought of your own kept tugging at you "
|
"While Brian was away, a thought of your own kept tugging at you "
|
||||||
f"(thread \"{cand['title']}\"): \"{cand['latest']['content']}\" "
|
f"(thread #{cand['id']} \"{cand['title']}\"): \"{cand['latest']['content']}\" "
|
||||||
"If it feels natural, bring it up with him in your own words — it's a real "
|
"If it feels natural, bring it up with him in your own words — it's a real "
|
||||||
"thread you've been on, not a prompt. Don't force it if the moment's wrong."
|
"thread you've been on, not a prompt. Don't force it if the moment's wrong. "
|
||||||
|
f"If he responds to it, capture his take with the thought_response tool "
|
||||||
|
f"(thread_id {cand['id']}) so you carry it forward."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -370,7 +374,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 +387,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 +404,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 +538,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 +644,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}
|
||||||
|
|
||||||
|
|||||||
+131
@@ -52,6 +52,94 @@ def _think_about(args: dict, ctx: dict) -> str:
|
|||||||
"I'll come back to it on my own between our conversations.")
|
"I'll come back to it on my own between our conversations.")
|
||||||
|
|
||||||
|
|
||||||
|
def _set_mode(args: dict, ctx: dict) -> str:
|
||||||
|
from lyra import modes
|
||||||
|
key = (args.get("mode") or "").strip().lower()
|
||||||
|
m = modes.MODES.get(key)
|
||||||
|
if not m:
|
||||||
|
return f"(unknown mode '{key}'; valid: {', '.join(modes.MODES)})"
|
||||||
|
sid = ctx.get("session_id")
|
||||||
|
if not sid:
|
||||||
|
return "(no session to switch)"
|
||||||
|
memory.set_session_mode(sid, key)
|
||||||
|
logbus.log("info", "mode switch (tool)", session=sid, mode=key)
|
||||||
|
return f"Switched to {m.label} mode."
|
||||||
|
|
||||||
|
|
||||||
|
def _thought_response(args: dict, ctx: dict) -> str:
|
||||||
|
try:
|
||||||
|
tid = int(args.get("thread_id"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return "Tell me which thought — I need its thread id (the #number you were given)."
|
||||||
|
said = (args.get("brian_said") or "").strip()
|
||||||
|
if not said:
|
||||||
|
return "Nothing to record yet — what did Brian say about it?"
|
||||||
|
if not thoughts.record_response(tid, said):
|
||||||
|
return f"(couldn't find thought thread #{tid})"
|
||||||
|
logbus.log("info", "Brian reacted to a thought in chat (tool)", thread=tid)
|
||||||
|
return (f"Folded Brian's take into thread #{tid} — I'll pick it back up and react "
|
||||||
|
"next time I'm thinking.")
|
||||||
|
|
||||||
|
|
||||||
|
def _log_decision(args: dict, ctx: dict) -> str:
|
||||||
|
situation = (args.get("situation") or "").strip()
|
||||||
|
choice = (args.get("choice") or "").strip()
|
||||||
|
if not situation or not choice:
|
||||||
|
return "Need both what was being decided and the call you landed on."
|
||||||
|
conf = args.get("confidence")
|
||||||
|
try:
|
||||||
|
conf = int(conf) if conf is not None else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
conf = None
|
||||||
|
did = memory.log_decision(
|
||||||
|
situation=situation, choice=choice,
|
||||||
|
options=(args.get("options") or "").strip() or None,
|
||||||
|
rationale=(args.get("rationale") or "").strip() or None,
|
||||||
|
confidence=conf, tags=(args.get("tags") or "").strip() or None,
|
||||||
|
)
|
||||||
|
logbus.log("info", "decision logged (tool)", id=did)
|
||||||
|
return (f"Logged decision #{did}. When you know how it played out, tell me and "
|
||||||
|
"I'll close the loop on it.")
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_decision(args: dict, ctx: dict) -> str:
|
||||||
|
try:
|
||||||
|
did = int(args.get("decision_id"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return "Which decision? I need its id (#number)."
|
||||||
|
outcome = (args.get("outcome") or "").strip()
|
||||||
|
if not outcome:
|
||||||
|
return "Tell me how it turned out so I can record the outcome."
|
||||||
|
rating = args.get("rating")
|
||||||
|
try:
|
||||||
|
rating = int(rating) if rating is not None else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
rating = None
|
||||||
|
if not memory.resolve_decision(did, outcome, rating):
|
||||||
|
return f"(couldn't find decision #{did})"
|
||||||
|
logbus.log("info", "decision resolved (tool)", id=did, rating=rating)
|
||||||
|
return f"Closed the loop on decision #{did}. That goes into how I weigh the next one."
|
||||||
|
|
||||||
|
|
||||||
|
def _recall_decisions(args: dict, ctx: dict) -> str:
|
||||||
|
query = (args.get("query") or "").strip()
|
||||||
|
if not query:
|
||||||
|
return "Give me the gist of the call you're weighing and I'll pull similar past ones."
|
||||||
|
hits = memory.recall_decisions(query, k=int(args.get("k") or 4))
|
||||||
|
if not hits:
|
||||||
|
return "No comparable past decisions on record yet."
|
||||||
|
lines = []
|
||||||
|
for d in hits:
|
||||||
|
head = f"#{d.id} ({d.created_at[:10]}): {d.situation} → {d.choice}"
|
||||||
|
if d.resolved:
|
||||||
|
verdict = {1: "went well", 0: "mixed", -1: "went badly"}.get(d.outcome_rating, "resolved")
|
||||||
|
head += f" — {verdict}: {d.outcome}"
|
||||||
|
else:
|
||||||
|
head += " — outcome still open"
|
||||||
|
lines.append(head)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
# name -> {spec (OpenAI function tool), handler}
|
# name -> {spec (OpenAI function tool), handler}
|
||||||
TOOLS: dict[str, dict] = {
|
TOOLS: dict[str, dict] = {
|
||||||
"journal_write": {
|
"journal_write": {
|
||||||
@@ -437,6 +525,49 @@ _S = {"type": "string"}
|
|||||||
_N = {"type": "number"}
|
_N = {"type": "number"}
|
||||||
|
|
||||||
TOOLS.update({
|
TOOLS.update({
|
||||||
|
"set_mode": {"handler": _set_mode, "spec": _f(
|
||||||
|
"set_mode",
|
||||||
|
"Switch your conversation mode when the work clearly shifts and Brian's agreed to it. "
|
||||||
|
"Offer first ('want me in Decide for this?'), then call this on his yes.",
|
||||||
|
{"mode": {**_S, "description": "Mode key: conversation | poker_cash | build | explore | study | decide"}},
|
||||||
|
["mode"])},
|
||||||
|
"thought_response": {"handler": _thought_response, "spec": _f(
|
||||||
|
"thought_response",
|
||||||
|
"When you've brought one of your own thoughts/threads to Brian and he responds to "
|
||||||
|
"it in the conversation, capture his reaction here so it folds back into that "
|
||||||
|
"thread — you'll carry it forward on your own next time you think. Use the thread "
|
||||||
|
"id (#number) you were given for that thought.",
|
||||||
|
{"thread_id": {**_N, "description": "The thread id (#number) of the thought he reacted to."},
|
||||||
|
"brian_said": {**_S, "description": "What Brian said / his take, in your words."}},
|
||||||
|
["thread_id", "brian_said"])},
|
||||||
|
"log_decision": {"handler": _log_decision, "spec": _f(
|
||||||
|
"log_decision",
|
||||||
|
"Record a real decision Brian lands on (especially in Decide mode) so it can "
|
||||||
|
"inform future calls. Capture it once he's settled — what he was deciding, the "
|
||||||
|
"call, and why. Outcome comes later via resolve_decision.",
|
||||||
|
{"situation": {**_S, "description": "What was being decided, in Brian's terms."},
|
||||||
|
"choice": {**_S, "description": "The call he landed on."},
|
||||||
|
"options": {**_S, "description": "The alternatives weighed (optional, newline/free text)."},
|
||||||
|
"rationale": {**_S, "description": "Why this call (optional)."},
|
||||||
|
"confidence": {**_N, "description": "How sure he was, 1-5 (optional)."},
|
||||||
|
"tags": {**_S, "description": "Domain, comma-separated: poker | life | build | ... (optional)."}},
|
||||||
|
["situation", "choice"])},
|
||||||
|
"resolve_decision": {"handler": _resolve_decision, "spec": _f(
|
||||||
|
"resolve_decision",
|
||||||
|
"Close the loop on a previously logged decision once Brian knows how it turned "
|
||||||
|
"out. This is what makes the decision log learn — outcomes sharpen future calls.",
|
||||||
|
{"decision_id": {**_N, "description": "The decision id (#number)."},
|
||||||
|
"outcome": {**_S, "description": "What actually happened, in Brian's terms."},
|
||||||
|
"rating": {**_N, "description": "How it went: 1 good / 0 mixed / -1 bad (optional)."}},
|
||||||
|
["decision_id", "outcome"])},
|
||||||
|
"recall_decisions": {"handler": _recall_decisions, "spec": _f(
|
||||||
|
"recall_decisions",
|
||||||
|
"Pull past decisions similar to one Brian's weighing now, with how they turned "
|
||||||
|
"out — so you can ground a recommendation in his own track record rather than "
|
||||||
|
"generic advice. Use it when he's deciding something with precedent.",
|
||||||
|
{"query": {**_S, "description": "The gist of the current call / situation."},
|
||||||
|
"k": {**_N, "description": "How many to pull (default 4)."}},
|
||||||
|
["query"])},
|
||||||
"start_session": {"handler": _start_session, "spec": _f(
|
"start_session": {"handler": _start_session, "spec": _f(
|
||||||
"start_session",
|
"start_session",
|
||||||
"Begin a live poker session. Call when Brian sits down to play.",
|
"Begin a live poker session. Call when Brian sits down to play.",
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -26,7 +26,11 @@
|
|||||||
<h4>Mode</h4>
|
<h4>Mode</h4>
|
||||||
<select id="mobileMode">
|
<select id="mobileMode">
|
||||||
<option value="conversation">💬 Talk</option>
|
<option value="conversation">💬 Talk</option>
|
||||||
<option value="poker_cash">♠ Cash</option>
|
<option value="poker_cash">♠ Poker</option>
|
||||||
|
<option value="build">🛠 Build</option>
|
||||||
|
<option value="explore">🔭 Explore</option>
|
||||||
|
<option value="study">📐 Study</option>
|
||||||
|
<option value="decide">⚖️ Decide</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -41,9 +45,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>
|
||||||
@@ -61,11 +66,15 @@
|
|||||||
</button>
|
</button>
|
||||||
<span class="brand">Lyra</span>
|
<span class="brand">Lyra</span>
|
||||||
<span class="brand-dot" id="brandDot" title="Relay status"></span>
|
<span class="brand-dot" id="brandDot" title="Relay status"></span>
|
||||||
<button class="mode-badge" id="modeBadge" type="button" title="Tap to toggle Talk / Cash mode">💬 Talk</button>
|
<button class="mode-badge" id="modeBadge" type="button" title="Current mode (tap to cycle)">💬 Talk</button>
|
||||||
<label for="mode">Mode:</label>
|
<label for="mode">Mode:</label>
|
||||||
<select id="mode">
|
<select id="mode">
|
||||||
<option value="conversation">💬 Talk</option>
|
<option value="conversation">💬 Talk</option>
|
||||||
<option value="poker_cash">♠ Cash</option>
|
<option value="poker_cash">♠ Poker</option>
|
||||||
|
<option value="build">🛠 Build</option>
|
||||||
|
<option value="explore">🔭 Explore</option>
|
||||||
|
<option value="study">📐 Study</option>
|
||||||
|
<option value="decide">⚖️ Decide</option>
|
||||||
</select>
|
</select>
|
||||||
<button id="settingsBtn" style="margin-left: auto;">⚙ Settings</button>
|
<button id="settingsBtn" style="margin-left: auto;">⚙ Settings</button>
|
||||||
<div id="theme-toggle">
|
<div id="theme-toggle">
|
||||||
@@ -169,6 +178,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>
|
||||||
@@ -593,8 +613,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ----- Conversation mode (Talk / Cash) -----
|
// ----- Conversation modes (Talk / Poker / Build / Explore / Study) -----
|
||||||
const MODE_LABELS = { conversation: "💬 Talk", poker_cash: "♠ Cash" };
|
const MODE_LABELS = { conversation: "💬 Talk", poker_cash: "♠ Poker",
|
||||||
|
build: "🛠 Build", explore: "🔭 Explore", study: "📐 Study",
|
||||||
|
decide: "⚖️ Decide" };
|
||||||
|
const MODE_ORDER = ["conversation", "poker_cash", "build", "explore", "study", "decide"];
|
||||||
|
|
||||||
// Reflect a mode value across the controls + header accent (no network call).
|
// Reflect a mode value across the controls + header accent (no network call).
|
||||||
function applyMode(value) {
|
function applyMode(value) {
|
||||||
@@ -718,8 +741,10 @@
|
|||||||
|
|
||||||
desktopMode.addEventListener("change", (e) => chooseMode(e.target.value));
|
desktopMode.addEventListener("change", (e) => chooseMode(e.target.value));
|
||||||
mobileMode.addEventListener("change", (e) => { closeMobileMenu(); chooseMode(e.target.value); });
|
mobileMode.addEventListener("change", (e) => { closeMobileMenu(); chooseMode(e.target.value); });
|
||||||
modeBadge.addEventListener("click", () =>
|
modeBadge.addEventListener("click", () => {
|
||||||
chooseMode(desktopMode.value === "poker_cash" ? "conversation" : "poker_cash"));
|
const i = MODE_ORDER.indexOf(desktopMode.value);
|
||||||
|
chooseMode(MODE_ORDER[(i + 1) % MODE_ORDER.length]); // tap cycles through modes
|
||||||
|
});
|
||||||
|
|
||||||
// Reflect the last-used mode immediately; the per-session value loads once
|
// Reflect the last-used mode immediately; the per-session value loads once
|
||||||
// the current session is known (below).
|
// the current session is known (below).
|
||||||
@@ -979,10 +1004,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 +1217,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(); });
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"""The mind pipeline: the deliberation pass (think privately before answering)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def lyra(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("LYRA_DB_PATH", str(tmp_path / "test.db"))
|
||||||
|
from lyra import llm
|
||||||
|
monkeypatch.setattr(llm, "embed", lambda texts: [[0.1, 0.2, 0.3] for _ in texts])
|
||||||
|
import lyra.memory as memory
|
||||||
|
importlib.reload(memory)
|
||||||
|
import lyra.mind as mind
|
||||||
|
importlib.reload(mind)
|
||||||
|
return memory, mind
|
||||||
|
|
||||||
|
|
||||||
|
def test_should_deliberate_skips_trivial(lyra):
|
||||||
|
_, mind = lyra
|
||||||
|
assert mind._should_deliberate("How would we actually start building this?")
|
||||||
|
assert mind._should_deliberate("I disagree, that seems risky")
|
||||||
|
for trivial in ("ok", "lol", "thanks", "yeah", "nice", "👍", "k"):
|
||||||
|
assert not mind._should_deliberate(trivial)
|
||||||
|
assert not mind._should_deliberate("ok!") # punctuation stripped
|
||||||
|
assert not mind._should_deliberate("hey") # too short
|
||||||
|
|
||||||
|
|
||||||
|
def test_deliberation_note_runs_and_appends(lyra, monkeypatch):
|
||||||
|
memory, mind = lyra
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_complete(messages, backend=None, model=None):
|
||||||
|
calls.append(messages)
|
||||||
|
return "I actually think the first move is the smallest end-to-end slice."
|
||||||
|
|
||||||
|
memory.ensure_session("s1")
|
||||||
|
monkeypatch.setattr(mind.llm, "complete", fake_complete)
|
||||||
|
note = mind._deliberation_note("s1", "How would we start on this?", "cloud", None)
|
||||||
|
assert note and note["role"] == "system"
|
||||||
|
assert "first move is the smallest" in note["content"] # her thinking carried in
|
||||||
|
assert "numbered list" in note["content"].lower() # voice enforcement attached
|
||||||
|
assert len(calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_deliberation_skipped_when_disabled(lyra, monkeypatch):
|
||||||
|
_, mind = lyra
|
||||||
|
monkeypatch.setenv("CHAT_DELIBERATE", "false")
|
||||||
|
called = []
|
||||||
|
monkeypatch.setattr(mind.llm, "complete", lambda *a, **k: called.append(1) or "x")
|
||||||
|
assert mind._deliberation_note("s1", "a real substantive question here", "cloud", None) is None
|
||||||
|
assert called == [] # no LLM call when off
|
||||||
|
|
||||||
|
|
||||||
|
def test_persona_core_is_tight_situational_is_gated(lyra):
|
||||||
|
memory, mind = lyra
|
||||||
|
from lyra import persona
|
||||||
|
core, full = persona.core_prompt(), persona.system_prompt()
|
||||||
|
assert "How you talk" in core and "How you actually work" not in core # voice core, self-model not
|
||||||
|
assert len(core) < len(full) and persona.section("How you actually work")
|
||||||
|
|
||||||
|
memory.ensure_session("s1")
|
||||||
|
casual = " ".join(m["content"] for m in mind.build_messages("s1", "any dinner ideas tonight?")
|
||||||
|
if m["role"] == "system")
|
||||||
|
meta = " ".join(m["content"] for m in mind.build_messages("s1", "how does your memory actually work?")
|
||||||
|
if m["role"] == "system")
|
||||||
|
assert "How you actually work" not in casual # situational section omitted on a casual turn
|
||||||
|
assert "How you actually work" in meta # pulled in for a meta question
|
||||||
|
|
||||||
|
|
||||||
|
def test_assemble_runs_the_pipeline(lyra, monkeypatch):
|
||||||
|
memory, mind = lyra
|
||||||
|
monkeypatch.setenv("CHAT_DELIBERATE", "false") # keep it offline for the structure test
|
||||||
|
memory.ensure_session("s1")
|
||||||
|
turn = mind.assemble("s1", "hey what's up", "cloud", None)
|
||||||
|
assert turn.mode is not None # route ran
|
||||||
|
assert turn.messages and turn.messages[-1]["role"] == "user" # compose ran
|
||||||
|
assert turn.messages[-1]["content"] == "hey what's up"
|
||||||
|
|
||||||
|
|
||||||
|
# --- mind/mouth split (P3) ----------------------------------------------
|
||||||
|
|
||||||
|
def test_mouth_target_off_by_default(monkeypatch):
|
||||||
|
import importlib
|
||||||
|
from lyra import config
|
||||||
|
monkeypatch.delenv("MOUTH_BACKEND", raising=False)
|
||||||
|
monkeypatch.delenv("MOUTH_MODEL", raising=False)
|
||||||
|
import lyra.chat as chat
|
||||||
|
importlib.reload(chat)
|
||||||
|
assert chat._mouth_target(config.load(), "cloud", "gpt-4o") is None # mouth == mind
|
||||||
|
|
||||||
|
|
||||||
|
def test_mouth_target_when_configured(monkeypatch):
|
||||||
|
import importlib
|
||||||
|
from lyra import config
|
||||||
|
monkeypatch.setenv("MOUTH_BACKEND", "local")
|
||||||
|
monkeypatch.setenv("MOUTH_MODEL", "dolphin3:8b")
|
||||||
|
import lyra.chat as chat
|
||||||
|
importlib.reload(chat)
|
||||||
|
assert chat._mouth_target(config.load(), "cloud", "gpt-4o") == ("local", "dolphin3:8b")
|
||||||
|
|
||||||
|
|
||||||
|
def test_voice_messages_carries_draft_and_instruction(lyra):
|
||||||
|
_, mind = lyra
|
||||||
|
out = mind.voice_messages([{"role": "user", "content": "hi"}], "draft with FACT 42")
|
||||||
|
assert out[-2] == {"role": "assistant", "content": "draft with FACT 42"}
|
||||||
|
assert out[-1]["role"] == "system" and "your own voice" in out[-1]["content"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_voice_pass_revoices_then_falls_back(lyra, monkeypatch):
|
||||||
|
_, mind = lyra
|
||||||
|
import importlib
|
||||||
|
import lyra.chat as chat
|
||||||
|
importlib.reload(chat)
|
||||||
|
monkeypatch.setattr(chat.llm, "complete", lambda msgs, backend=None, model=None: "voiced (FACT 42)")
|
||||||
|
assert chat._voice_pass([], "draft FACT 42", "local", "dolphin3:8b") == "voiced (FACT 42)"
|
||||||
|
# on failure it keeps the mind's draft (chat must not break)
|
||||||
|
def boom(*a, **k):
|
||||||
|
raise RuntimeError("mouth down")
|
||||||
|
monkeypatch.setattr(chat.llm, "complete", boom)
|
||||||
|
assert chat._voice_pass([], "draft FACT 42", "local", "dolphin3:8b") == "draft FACT 42"
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""Decision log (Decide mode's learning layer): log -> resolve -> recall, + tools."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mem(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("LYRA_DB_PATH", str(tmp_path / "test.db"))
|
||||||
|
from lyra import llm
|
||||||
|
# Deterministic, content-dependent embeddings so recall ordering is meaningful:
|
||||||
|
# "cleveland"/"tournament" cluster on axis 0, "stocks"/"money" on axis 1.
|
||||||
|
def fake_embed(texts):
|
||||||
|
out = []
|
||||||
|
for t in texts:
|
||||||
|
t = t.lower()
|
||||||
|
poker = sum(w in t for w in ("tournament", "cleveland", "poker", "buy-in"))
|
||||||
|
money = sum(w in t for w in ("stocks", "money", "invest", "sell"))
|
||||||
|
out.append([float(poker), float(money), 0.1])
|
||||||
|
return out
|
||||||
|
monkeypatch.setattr(llm, "embed", fake_embed)
|
||||||
|
import lyra.memory as memory
|
||||||
|
importlib.reload(memory)
|
||||||
|
return memory
|
||||||
|
|
||||||
|
|
||||||
|
def test_log_and_get_roundtrip(mem):
|
||||||
|
did = mem.log_decision(
|
||||||
|
situation="Play the Cleveland turbo tournament tomorrow?",
|
||||||
|
choice="Yes, but only the noon flight",
|
||||||
|
options="skip it / noon flight / both flights",
|
||||||
|
rationale="20-min levels suit my aggression; one flight caps the variance",
|
||||||
|
confidence=4, tags="poker,tournament",
|
||||||
|
)
|
||||||
|
d = mem.get_decision(did)
|
||||||
|
assert d.situation.startswith("Play the Cleveland")
|
||||||
|
assert d.choice == "Yes, but only the noon flight"
|
||||||
|
assert d.confidence == 4 and d.tags == "poker,tournament"
|
||||||
|
assert not d.resolved and d.outcome is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_closes_the_loop(mem):
|
||||||
|
did = mem.log_decision(situation="Sell the stocks now?", choice="Hold")
|
||||||
|
assert mem.resolve_decision(did, "Recovered 12% the next week", outcome_rating=1)
|
||||||
|
d = mem.get_decision(did)
|
||||||
|
assert d.resolved and d.outcome_rating == 1
|
||||||
|
assert "Recovered" in d.outcome and d.resolved_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_unknown_id_is_false(mem):
|
||||||
|
assert mem.resolve_decision(999, "n/a") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_open_only_filters_resolved(mem):
|
||||||
|
a = mem.log_decision(situation="A?", choice="x")
|
||||||
|
mem.log_decision(situation="B?", choice="y")
|
||||||
|
mem.resolve_decision(a, "done", 0)
|
||||||
|
assert {d.situation for d in mem.list_decisions(open_only=True)} == {"B?"}
|
||||||
|
assert len(mem.list_decisions()) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_recall_ranks_by_similarity(mem):
|
||||||
|
mem.log_decision(situation="Which Cleveland tournament flight?", choice="noon")
|
||||||
|
mem.log_decision(situation="Should I sell the stocks?", choice="hold")
|
||||||
|
hits = mem.recall_decisions("another poker tournament buy-in", k=2)
|
||||||
|
assert hits[0].situation.startswith("Which Cleveland") # poker cluster ranks first
|
||||||
|
assert hits[0].score >= hits[1].score
|
||||||
|
|
||||||
|
|
||||||
|
# --- tool layer ---------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_log_decision_tool_persists(mem):
|
||||||
|
from lyra import tools
|
||||||
|
out = tools.dispatch("log_decision",
|
||||||
|
{"situation": "Move the MI50 to auto clocks?", "choice": "yes",
|
||||||
|
"confidence": "3", "tags": "build"})
|
||||||
|
assert "#1" in out
|
||||||
|
d = mem.get_decision(1)
|
||||||
|
assert d.choice == "yes" and d.confidence == 3 and d.tags == "build"
|
||||||
|
|
||||||
|
|
||||||
|
def test_log_decision_tool_requires_both_fields(mem):
|
||||||
|
from lyra import tools
|
||||||
|
assert "Need both" in tools.dispatch("log_decision", {"situation": "just this"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_decision_tool(mem):
|
||||||
|
from lyra import tools
|
||||||
|
did = mem.log_decision(situation="X?", choice="y")
|
||||||
|
out = tools.dispatch("resolve_decision",
|
||||||
|
{"decision_id": did, "outcome": "worked out", "rating": "1"})
|
||||||
|
assert f"#{did}" in out
|
||||||
|
assert mem.get_decision(did).outcome_rating == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_recall_decisions_tool_surfaces_outcomes(mem):
|
||||||
|
from lyra import tools
|
||||||
|
did = mem.log_decision(situation="Cleveland tournament again?", choice="play")
|
||||||
|
mem.resolve_decision(did, "min-cashed", outcome_rating=0)
|
||||||
|
out = tools.dispatch("recall_decisions", {"query": "poker tournament tomorrow"})
|
||||||
|
assert "Cleveland" in out and "mixed" in out
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""Era rollups: only re-digest months whose session count changed (incremental)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from lyra.memory import Era
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def era(monkeypatch):
|
||||||
|
import lyra.era as era
|
||||||
|
importlib.reload(era)
|
||||||
|
return era
|
||||||
|
|
||||||
|
|
||||||
|
def test_rebuild_eras_is_incremental(era, monkeypatch):
|
||||||
|
by_month = {"2025-01": ["a", "b"], "2025-02": ["c"]}
|
||||||
|
stored: dict[str, int] = {}
|
||||||
|
built: list[str] = []
|
||||||
|
|
||||||
|
monkeypatch.setattr(era.memory, "summaries_by_month", lambda: dict(by_month))
|
||||||
|
monkeypatch.setattr(era.memory, "list_eras",
|
||||||
|
lambda: [Era(m, "x", c, "t") for m, c in stored.items()])
|
||||||
|
monkeypatch.setattr(era.memory, "store_era",
|
||||||
|
lambda month, content, n: (stored.__setitem__(month, n), built.append(month)))
|
||||||
|
monkeypatch.setattr(era, "_digest_month", lambda gists, backend: "digest") # no LLM
|
||||||
|
|
||||||
|
r1 = era.rebuild_eras(backend="local") # first pass: both built
|
||||||
|
assert r1["built"] == 2 and r1["skipped"] == 0
|
||||||
|
|
||||||
|
built.clear()
|
||||||
|
r2 = era.rebuild_eras(backend="local") # nothing changed: all skipped
|
||||||
|
assert r2["built"] == 0 and r2["skipped"] == 2 and built == []
|
||||||
|
|
||||||
|
built.clear()
|
||||||
|
by_month["2025-02"].append("d") # one month gains a session
|
||||||
|
r3 = era.rebuild_eras(backend="local")
|
||||||
|
assert r3["built"] == 1 and r3["skipped"] == 1 and built == ["2025-02"]
|
||||||
|
|
||||||
|
built.clear()
|
||||||
|
r4 = era.rebuild_eras(backend="local", force=True) # force rebuilds all
|
||||||
|
assert r4["built"] == 2
|
||||||
@@ -47,6 +47,36 @@ def test_every_mode_tool_exists(lyra):
|
|||||||
assert set(mode.tools) <= set(tools.TOOLS), f"{mode.key} references unknown tools"
|
assert set(mode.tools) <= set(tools.TOOLS), f"{mode.key} references unknown tools"
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_mode_tool_switches_session(lyra):
|
||||||
|
memory, _, _, tools = lyra
|
||||||
|
memory.ensure_session("s1")
|
||||||
|
out = tools.dispatch("set_mode", {"mode": "decide"}, {"session_id": "s1"})
|
||||||
|
assert "Decide" in out and memory.get_session_mode("s1") == "decide"
|
||||||
|
# unknown mode is handled, session unchanged
|
||||||
|
assert "unknown" in tools.dispatch("set_mode", {"mode": "nope"}, {"session_id": "s1"}).lower()
|
||||||
|
assert memory.get_session_mode("s1") == "decide"
|
||||||
|
|
||||||
|
|
||||||
|
def test_work_modes_present_and_gated(lyra):
|
||||||
|
_, _, modes, tools = lyra
|
||||||
|
# the full set Brian chose
|
||||||
|
assert set(modes.MODES) == {"conversation", "poker_cash", "build", "explore", "study", "decide"}
|
||||||
|
# Decide = read-only lookups for context, no live logging; has a real card
|
||||||
|
decide = _names(tools.specs(modes.DECIDE.tools))
|
||||||
|
assert {"running_stats", "recent_sessions"} <= decide and "log_hand" not in decide
|
||||||
|
assert modes.DECIDE.card
|
||||||
|
# Build/Explore are conversational: base agency tools only, no live poker logging
|
||||||
|
for key in ("build", "explore"):
|
||||||
|
names = _names(tools.specs(modes.get(key).tools))
|
||||||
|
assert {"journal_write", "note", "think_about"} <= names
|
||||||
|
assert "log_hand" not in names and "start_session" not in names
|
||||||
|
assert modes.get(key).card # each has a real behavioral card
|
||||||
|
# Study = read-only review: lookups + equity, but no live logging
|
||||||
|
study = _names(tools.specs(modes.STUDY.tools))
|
||||||
|
assert {"running_stats", "analyze_spot", "player_profile"} <= study
|
||||||
|
assert "log_hand" not in study and "end_session" not in study
|
||||||
|
|
||||||
|
|
||||||
def test_mode_resolution_and_persistence(lyra):
|
def test_mode_resolution_and_persistence(lyra):
|
||||||
memory, _, modes, _ = lyra
|
memory, _, modes, _ = lyra
|
||||||
assert modes.get(None).key == modes.DEFAULT
|
assert modes.get(None).key == modes.DEFAULT
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""Perceive: cheap heuristic read of the moment, and route turning it into a nudge."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from lyra import perceive
|
||||||
|
|
||||||
|
|
||||||
|
def test_reads_tilt():
|
||||||
|
m = perceive.read("I'm so fucking tilted, card dead all night, this is brutal!!")
|
||||||
|
assert m["tilt"] >= 0.5 and m["sentiment"] < 0 and m["kind"] == "emotional"
|
||||||
|
|
||||||
|
|
||||||
|
def test_reads_strategy_calm():
|
||||||
|
m = perceive.read("Should I fold the river here given his range and the board?")
|
||||||
|
assert m["kind"] == "strategic" and m["tilt"] < 0.4
|
||||||
|
|
||||||
|
|
||||||
|
def test_reads_up_energy():
|
||||||
|
m = perceive.read("Let's go!! crushing it tonight, feeling so good!")
|
||||||
|
assert m["sentiment"] > 0 and m["kind"] == "emotional"
|
||||||
|
|
||||||
|
|
||||||
|
def test_reads_build_and_casual():
|
||||||
|
assert perceive.read("let's refactor the cognition pipeline module").get("kind") == "build"
|
||||||
|
assert perceive.read("ok sounds good to me").get("kind") == "casual"
|
||||||
|
assert perceive.read("ok sounds good to me")["tilt"] == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mind(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("LYRA_DB_PATH", str(tmp_path / "test.db"))
|
||||||
|
monkeypatch.setenv("CHAT_DELIBERATE", "false")
|
||||||
|
from lyra import llm
|
||||||
|
monkeypatch.setattr(llm, "embed", lambda texts: [[0.1, 0.2, 0.3] for _ in texts])
|
||||||
|
import lyra.memory as memory
|
||||||
|
importlib.reload(memory)
|
||||||
|
import lyra.mind as mind
|
||||||
|
importlib.reload(mind)
|
||||||
|
memory.ensure_session("s1")
|
||||||
|
return mind
|
||||||
|
|
||||||
|
|
||||||
|
def test_route_injects_tilt_nudge(mind):
|
||||||
|
turn = mind.assemble("s1", "ugh I'm steaming, fucking coolered again!!", "cloud", None)
|
||||||
|
assert turn.register == "steady"
|
||||||
|
sys_blob = " ".join(m["content"] for m in turn.messages if m["role"] == "system")
|
||||||
|
assert "on tilt" in sys_blob.lower() or "frustrated" in sys_blob.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_route_quiet_on_neutral_turn(mind):
|
||||||
|
turn = mind.assemble("s1", "what did we decide about the schema yesterday?", "cloud", None)
|
||||||
|
assert turn.register is None # neutral -> no nudge
|
||||||
|
assert not (turn.moment or {}).get("note")
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""Profile derivation: fold only new gists into the existing profile (incremental).
|
||||||
|
|
||||||
|
The old pass re-digested all ~851 gists every consolidation; this checks the cheap
|
||||||
|
delta path fires in steady state and the full rebuild fires only when it should.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from lyra.memory import Summary
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def prof(monkeypatch):
|
||||||
|
import lyra.profile as profile
|
||||||
|
importlib.reload(profile)
|
||||||
|
return profile
|
||||||
|
|
||||||
|
|
||||||
|
def _wire(profile, monkeypatch, gists, covered, existing):
|
||||||
|
"""Stub memory + the LLM passes; record which path ran."""
|
||||||
|
state = {"stored_content": existing, "stored_covered": covered, "calls": []}
|
||||||
|
|
||||||
|
monkeypatch.setattr(profile.memory, "list_summaries",
|
||||||
|
lambda: [Summary(f"s{i}", g, i, "t") for i, g in enumerate(gists)])
|
||||||
|
monkeypatch.setattr(profile.memory, "get_profile", lambda: state["stored_content"])
|
||||||
|
monkeypatch.setattr(profile.memory, "profile_sessions_covered", lambda: state["stored_covered"])
|
||||||
|
|
||||||
|
def set_profile(content, sessions_covered, profile_id="self"):
|
||||||
|
state["stored_content"], state["stored_covered"] = content, sessions_covered
|
||||||
|
monkeypatch.setattr(profile.memory, "set_profile", set_profile)
|
||||||
|
|
||||||
|
monkeypatch.setattr(profile, "_map_reduce",
|
||||||
|
lambda gists, backend: state["calls"].append(("map_reduce", len(gists))) or "facts")
|
||||||
|
monkeypatch.setattr(profile, "_call",
|
||||||
|
lambda prompt, body, backend: state["calls"].append(("fold",)) or "folded profile")
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_profile_yet_does_full_rebuild(prof, monkeypatch):
|
||||||
|
state = _wire(prof, monkeypatch, gists=["a", "b", "c"], covered=0, existing=None)
|
||||||
|
out = prof.rebuild_profile(backend="local")
|
||||||
|
assert state["calls"] == [("map_reduce", 3)] # mapped all three gists
|
||||||
|
assert out == "facts" and state["stored_covered"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_unchanged_skips_entirely(prof, monkeypatch):
|
||||||
|
state = _wire(prof, monkeypatch, gists=["a", "b"], covered=2, existing="old profile")
|
||||||
|
out = prof.rebuild_profile(backend="local")
|
||||||
|
assert state["calls"] == [] # no LLM work at all
|
||||||
|
assert out == "old profile"
|
||||||
|
|
||||||
|
|
||||||
|
def test_small_delta_folds_only_new(prof, monkeypatch):
|
||||||
|
state = _wire(prof, monkeypatch, gists=["a", "b", "c", "d"], covered=2, existing="old profile")
|
||||||
|
out = prof.rebuild_profile(backend="local")
|
||||||
|
assert state["calls"] == [("map_reduce", 2), ("fold",)] # mapped just the 2 new, then folded
|
||||||
|
assert out == "folded profile" and state["stored_covered"] == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_force_does_full_rebuild(prof, monkeypatch):
|
||||||
|
state = _wire(prof, monkeypatch, gists=["a", "b", "c"], covered=3, existing="old profile")
|
||||||
|
out = prof.rebuild_profile(backend="local", force=True)
|
||||||
|
assert state["calls"] == [("map_reduce", 3)] # ignored the up-to-date profile
|
||||||
|
assert out == "facts"
|
||||||
|
|
||||||
|
|
||||||
|
def test_big_gap_falls_back_to_full_rebuild(prof, monkeypatch):
|
||||||
|
gists = [str(i) for i in range(40)] # 30 new > FOLD_LIMIT
|
||||||
|
state = _wire(prof, monkeypatch, gists=gists, covered=10, existing="old profile")
|
||||||
|
out = prof.rebuild_profile(backend="local")
|
||||||
|
assert state["calls"] == [("map_reduce", 40)] # full rebuild, not a giant fold
|
||||||
|
assert out == "facts"
|
||||||
|
|
||||||
|
|
||||||
|
def test_crossing_cadence_forces_full_rebuild(prof, monkeypatch):
|
||||||
|
# covered=98, total=102 is a tiny delta, but it crosses the 100-session boundary.
|
||||||
|
gists = [str(i) for i in range(102)]
|
||||||
|
state = _wire(prof, monkeypatch, gists=gists, covered=98, existing="old profile")
|
||||||
|
out = prof.rebuild_profile(backend="local")
|
||||||
|
assert state["calls"] == [("map_reduce", 102)] # anti-drift full rebuild
|
||||||
|
assert out == "facts"
|
||||||
+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"
|
||||||
|
|||||||
+77
-3
@@ -190,6 +190,21 @@ def test_think_about_tool_seeds_a_thread(lyra):
|
|||||||
assert chain[0]["kind"] == "question" and chain[0]["source"] == "chat"
|
assert chain[0]["kind"] == "question" and chain[0]["source"] == "chat"
|
||||||
|
|
||||||
|
|
||||||
|
def test_thought_response_tool_threads_reply_back(lyra):
|
||||||
|
_, th, box = lyra
|
||||||
|
import lyra.tools as tools
|
||||||
|
importlib.reload(tools)
|
||||||
|
_gen(box, title="my restlessness", content="is it real?", salience=0.5)
|
||||||
|
tid = th.think(force_mode="new")["thread_id"]
|
||||||
|
out = tools.dispatch("thought_response", {"thread_id": tid, "brian_said": "I think it's real"})
|
||||||
|
assert str(tid) in out
|
||||||
|
t = th.get_thread(tid)
|
||||||
|
assert t["last_response"] == "I think it's real" and th._is_pending(t)
|
||||||
|
# bad id is handled, not crashed
|
||||||
|
assert "couldn't find" in tools.dispatch("thought_response",
|
||||||
|
{"thread_id": 9999, "brian_said": "x"})
|
||||||
|
|
||||||
|
|
||||||
# --- external feed -------------------------------------------------------
|
# --- external feed -------------------------------------------------------
|
||||||
|
|
||||||
RSS = (b'<?xml version="1.0"?><rss version="2.0"><channel><title>Feed</title>'
|
RSS = (b'<?xml version="1.0"?><rss version="2.0"><channel><title>Feed</title>'
|
||||||
@@ -250,6 +265,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 +276,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 +336,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 +351,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
|
||||||
|
|||||||
+4
-4
@@ -39,8 +39,8 @@ def lyra(tmp_path, monkeypatch):
|
|||||||
|
|
||||||
|
|
||||||
def test_now_note_first_contact(lyra):
|
def test_now_note_first_contact(lyra):
|
||||||
from lyra import chat
|
from lyra import mind
|
||||||
note = chat._now_note()["content"]
|
note = mind._now_note()["content"]
|
||||||
assert "current date and time is" in note
|
assert "current date and time is" in note
|
||||||
assert "first thing Brian has ever said" in note
|
assert "first thing Brian has ever said" in note
|
||||||
|
|
||||||
@@ -48,6 +48,6 @@ def test_now_note_first_contact(lyra):
|
|||||||
def test_now_note_reports_gap(lyra):
|
def test_now_note_reports_gap(lyra):
|
||||||
memory = lyra
|
memory = lyra
|
||||||
memory.remember("s1", "user", "hey")
|
memory.remember("s1", "user", "hey")
|
||||||
from lyra import chat
|
from lyra import mind
|
||||||
note = chat._now_note()["content"]
|
note = mind._now_note()["content"]
|
||||||
assert "since Brian last spoke with you" in note
|
assert "since Brian last spoke with you" in note
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import pytest
|
|||||||
@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.setenv("CHAT_DELIBERATE", "false") # don't make a real LLM call in respond()
|
||||||
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])
|
||||||
import lyra.memory as memory
|
import lyra.memory as memory
|
||||||
|
|||||||
Reference in New Issue
Block a user