Compare commits
2 Commits
149e9a6dd5
...
97afa82594
| Author | SHA1 | Date | |
|---|---|---|---|
| 97afa82594 | |||
| ea30c3dd67 |
@@ -48,3 +48,4 @@ INTROSPECTION_MODEL=
|
|||||||
PING_AUTO_SALIENCE=0.8 # a thought this salient auto-pings even without an explicit reach-out
|
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)
|
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
|
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)
|
||||||
|
|||||||
@@ -101,6 +101,61 @@ def _render(messages: list[Message]) -> str:
|
|||||||
return "\n\n".join(f"[{m['role']}]\n{m['content']}" for m in messages)
|
return "\n\n".join(f"[{m['role']}]\n{m['content']}" for m in messages)
|
||||||
|
|
||||||
|
|
||||||
|
# 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 _deliberate(messages: list[Message], 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(messages + [{"role": "system", "content": _DELIBERATE_SYS}],
|
||||||
|
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, messages: list[Message]) -> 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(messages, backend, model)
|
||||||
|
if not thinking:
|
||||||
|
return None
|
||||||
|
logbus.log("info", "deliberated", session=session_id, chars=len(thinking), detail=thinking)
|
||||||
|
return _answer_from(thinking)
|
||||||
|
|
||||||
|
|
||||||
def build_messages(session_id: str, user_msg: str,
|
def build_messages(session_id: str, user_msg: str,
|
||||||
mode: modes.Mode | None = None) -> list[Message]:
|
mode: modes.Mode | None = None) -> list[Message]:
|
||||||
"""Assemble the full, tiered message list for one turn."""
|
"""Assemble the full, tiered message list for one turn."""
|
||||||
@@ -211,6 +266,11 @@ def respond(session_id: str, user_msg: str, backend: Backend = "cloud",
|
|||||||
mode = modes.get(memory.get_session_mode(session_id))
|
mode = modes.get(memory.get_session_mode(session_id))
|
||||||
messages = build_messages(session_id, user_msg, mode=mode)
|
messages = build_messages(session_id, user_msg, mode=mode)
|
||||||
|
|
||||||
|
# Live thought loop: think privately about what to actually say before answering.
|
||||||
|
note = _deliberation_note(session_id, user_msg, backend, model, messages)
|
||||||
|
if note:
|
||||||
|
messages.append(note)
|
||||||
|
|
||||||
# Tool loop: offer Lyra her tools (scoped to the mode); if she calls one, run it
|
# Tool loop: offer Lyra her tools (scoped to the mode); if she calls one, run it
|
||||||
# and feed the result back so she can continue, until she returns a text reply.
|
# and feed the result back so she can continue, until she returns a text reply.
|
||||||
tool_specs = toolkit.specs(mode.tools) if backend in TOOL_BACKENDS else None
|
tool_specs = toolkit.specs(mode.tools) if backend in TOOL_BACKENDS else None
|
||||||
@@ -262,6 +322,12 @@ def respond_stream(session_id: str, user_msg: str, backend: Backend = "cloud",
|
|||||||
|
|
||||||
mode = modes.get(memory.get_session_mode(session_id))
|
mode = modes.get(memory.get_session_mode(session_id))
|
||||||
messages = build_messages(session_id, user_msg, mode=mode)
|
messages = build_messages(session_id, user_msg, mode=mode)
|
||||||
|
|
||||||
|
# Live thought loop: think privately about what to actually say before answering.
|
||||||
|
note = _deliberation_note(session_id, user_msg, backend, model, messages)
|
||||||
|
if note:
|
||||||
|
messages.append(note)
|
||||||
|
|
||||||
tool_specs = toolkit.specs(mode.tools) if backend in TOOL_BACKENDS else None
|
tool_specs = toolkit.specs(mode.tools) if backend in TOOL_BACKENDS else None
|
||||||
ctx = {"session_id": session_id, "backend": backend}
|
ctx = {"session_id": session_id, "backend": backend}
|
||||||
parts: list[str] = []
|
parts: list[str] = []
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ class Config:
|
|||||||
ping_cooldown_min: int # min minutes between AUTO pushes (explicit reach-outs bypass it)
|
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
|
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
|
||||||
# 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
|
||||||
@@ -79,6 +80,7 @@ def load() -> Config:
|
|||||||
ping_cooldown_min=int(os.getenv("PING_COOLDOWN_MIN", "60")),
|
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")),
|
digest_hour=int(os.getenv("DIGEST_HOUR", "18")),
|
||||||
|
chat_deliberate=os.getenv("CHAT_DELIBERATE", "true").lower() not in ("0", "false", "no"),
|
||||||
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")),
|
||||||
)
|
)
|
||||||
|
|||||||
+2
-2
@@ -37,8 +37,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")
|
||||||
|
|
||||||
# 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 + (
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+7
-4
@@ -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)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -335,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."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,21 @@ 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 _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.")
|
||||||
|
|
||||||
|
|
||||||
# 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 +452,15 @@ _S = {"type": "string"}
|
|||||||
_N = {"type": "number"}
|
_N = {"type": "number"}
|
||||||
|
|
||||||
TOOLS.update({
|
TOOLS.update({
|
||||||
|
"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"])},
|
||||||
"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.",
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""Live chat: 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.chat as chat
|
||||||
|
importlib.reload(chat)
|
||||||
|
return memory, chat
|
||||||
|
|
||||||
|
|
||||||
|
def test_should_deliberate_skips_trivial(lyra):
|
||||||
|
_, chat = lyra
|
||||||
|
assert chat._should_deliberate("How would we actually start building this?")
|
||||||
|
assert chat._should_deliberate("I disagree, that seems risky")
|
||||||
|
for trivial in ("ok", "lol", "thanks", "yeah", "nice", "👍", "k"):
|
||||||
|
assert not chat._should_deliberate(trivial)
|
||||||
|
assert not chat._should_deliberate("ok!") # punctuation stripped
|
||||||
|
assert not chat._should_deliberate("hey") # too short
|
||||||
|
|
||||||
|
|
||||||
|
def test_deliberation_note_runs_and_appends(lyra, monkeypatch):
|
||||||
|
_, chat = 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."
|
||||||
|
|
||||||
|
monkeypatch.setattr(chat.llm, "complete", fake_complete)
|
||||||
|
note = chat._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):
|
||||||
|
_, chat = lyra
|
||||||
|
monkeypatch.setenv("CHAT_DELIBERATE", "false")
|
||||||
|
called = []
|
||||||
|
monkeypatch.setattr(chat.llm, "complete", lambda *a, **k: called.append(1) or "x")
|
||||||
|
assert chat._deliberation_note("s1", "a real substantive question here", "cloud", None, []) is None
|
||||||
|
assert called == [] # no LLM call when off
|
||||||
@@ -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>'
|
||||||
|
|||||||
Reference in New Issue
Block a user