hindsight-livekit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hindsight_livekit/__init__.py +48 -0
- hindsight_livekit/_client.py +46 -0
- hindsight_livekit/agent.py +85 -0
- hindsight_livekit/config.py +87 -0
- hindsight_livekit/errors.py +7 -0
- hindsight_livekit/memory.py +391 -0
- hindsight_livekit/py.typed +0 -0
- hindsight_livekit/tools.py +168 -0
- hindsight_livekit-0.1.0.dist-info/METADATA +242 -0
- hindsight_livekit-0.1.0.dist-info/RECORD +12 -0
- hindsight_livekit-0.1.0.dist-info/WHEEL +4 -0
- hindsight_livekit-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Hindsight-LiveKit: Persistent memory for LiveKit voice agents.
|
|
2
|
+
|
|
3
|
+
Recalls what Hindsight knows about the user when the agent starts and before
|
|
4
|
+
each LLM reply, and retains the conversation as it happens, one Hindsight
|
|
5
|
+
document per session. Optional tools let the model search and store memories
|
|
6
|
+
itself, which also works with realtime models.
|
|
7
|
+
|
|
8
|
+
Basic usage::
|
|
9
|
+
|
|
10
|
+
from hindsight_livekit import HindsightAgent, HindsightMemory
|
|
11
|
+
|
|
12
|
+
@server.rtc_session()
|
|
13
|
+
async def entrypoint(ctx: JobContext):
|
|
14
|
+
session = AgentSession(stt=..., llm=..., tts=...)
|
|
15
|
+
|
|
16
|
+
memory = HindsightMemory(bank_id="user-123", document_id=ctx.room.name)
|
|
17
|
+
memory.attach(session) # retain the conversation
|
|
18
|
+
ctx.add_shutdown_callback(memory.aclose) # flush on hang-up
|
|
19
|
+
|
|
20
|
+
await session.start(
|
|
21
|
+
agent=HindsightAgent(memory=memory, instructions="You are a helpful voice assistant."),
|
|
22
|
+
room=ctx.room,
|
|
23
|
+
)
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from .agent import HindsightAgent
|
|
27
|
+
from .config import (
|
|
28
|
+
HindsightLiveKitConfig,
|
|
29
|
+
configure,
|
|
30
|
+
get_config,
|
|
31
|
+
reset_config,
|
|
32
|
+
)
|
|
33
|
+
from .errors import HindsightLiveKitError
|
|
34
|
+
from .memory import HindsightMemory
|
|
35
|
+
from .tools import create_hindsight_tools
|
|
36
|
+
|
|
37
|
+
__version__ = "0.1.0"
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"HindsightAgent",
|
|
41
|
+
"HindsightLiveKitConfig",
|
|
42
|
+
"HindsightLiveKitError",
|
|
43
|
+
"HindsightMemory",
|
|
44
|
+
"configure",
|
|
45
|
+
"create_hindsight_tools",
|
|
46
|
+
"get_config",
|
|
47
|
+
"reset_config",
|
|
48
|
+
]
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Shared Hindsight client resolution logic."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from importlib import metadata
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from hindsight_client import Hindsight
|
|
10
|
+
|
|
11
|
+
from .config import DEFAULT_HINDSIGHT_API_URL, HINDSIGHT_API_KEY_ENV, HINDSIGHT_API_URL_ENV, get_config
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
_VERSION = metadata.version("hindsight-livekit")
|
|
15
|
+
except metadata.PackageNotFoundError:
|
|
16
|
+
_VERSION = "0.0.0"
|
|
17
|
+
USER_AGENT = f"hindsight-livekit/{_VERSION}"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def resolve_client(
|
|
21
|
+
client: Hindsight | None,
|
|
22
|
+
hindsight_api_url: str | None,
|
|
23
|
+
api_key: str | None,
|
|
24
|
+
) -> Hindsight:
|
|
25
|
+
"""Resolve a Hindsight client from explicit args, global config, or env vars.
|
|
26
|
+
|
|
27
|
+
Order: explicit arguments, then ``configure()``, then the ``HINDSIGHT_API_URL`` /
|
|
28
|
+
``HINDSIGHT_API_KEY`` env vars, then Hindsight Cloud. A LiveKit worker is usually
|
|
29
|
+
configured through env vars, so they are honoured without a ``configure()`` call.
|
|
30
|
+
"""
|
|
31
|
+
if client is not None:
|
|
32
|
+
return client
|
|
33
|
+
|
|
34
|
+
config = get_config()
|
|
35
|
+
url = (
|
|
36
|
+
hindsight_api_url
|
|
37
|
+
or (config.hindsight_api_url if config else None)
|
|
38
|
+
or os.environ.get(HINDSIGHT_API_URL_ENV)
|
|
39
|
+
or DEFAULT_HINDSIGHT_API_URL
|
|
40
|
+
)
|
|
41
|
+
key = api_key or (config.api_key if config else None) or os.environ.get(HINDSIGHT_API_KEY_ENV)
|
|
42
|
+
|
|
43
|
+
kwargs: dict[str, Any] = {"base_url": url, "timeout": 30.0, "user_agent": USER_AGENT}
|
|
44
|
+
if key:
|
|
45
|
+
kwargs["api_key"] = key
|
|
46
|
+
return Hindsight(**kwargs)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""LiveKit ``Agent`` with Hindsight memory recall."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import AsyncIterable
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from livekit.agents import Agent, ModelSettings, llm
|
|
9
|
+
|
|
10
|
+
from .memory import HindsightMemory
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class HindsightAgent(Agent):
|
|
14
|
+
"""A LiveKit ``Agent`` that recalls Hindsight memories.
|
|
15
|
+
|
|
16
|
+
Use it in place of ``Agent`` (or subclass it) and pass the session's
|
|
17
|
+
:class:`HindsightMemory`::
|
|
18
|
+
|
|
19
|
+
class Assistant(HindsightAgent):
|
|
20
|
+
def __init__(self, memory: HindsightMemory) -> None:
|
|
21
|
+
super().__init__(memory=memory, instructions="You are a helpful voice assistant.")
|
|
22
|
+
|
|
23
|
+
Memory is recalled at two points:
|
|
24
|
+
|
|
25
|
+
* **When the agent starts** (``on_enter``): what is known about the user is
|
|
26
|
+
added to the agent's instructions. This works with STT-LLM-TTS pipelines and
|
|
27
|
+
realtime models alike. If you override ``on_enter``, call
|
|
28
|
+
``await super().on_enter()`` first.
|
|
29
|
+
* **Before each LLM reply** (``llm_node``): memories relevant to the user's
|
|
30
|
+
latest message are added to a copy of the chat context for that call only.
|
|
31
|
+
Realtime models don't use ``llm_node``, so this step only applies to
|
|
32
|
+
STT-LLM-TTS pipelines; give realtime agents the tools from
|
|
33
|
+
:func:`~hindsight_livekit.create_hindsight_tools` to look things up mid-call.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, *, memory: HindsightMemory, **kwargs: Any) -> None:
|
|
37
|
+
super().__init__(**kwargs)
|
|
38
|
+
self._memory = memory
|
|
39
|
+
# Instructions without recalled memories, so re-entering the agent (e.g. after
|
|
40
|
+
# a handoff back to it) replaces the memory block instead of stacking another.
|
|
41
|
+
self._base_instructions = self.instructions
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def memory(self) -> HindsightMemory:
|
|
45
|
+
return self._memory
|
|
46
|
+
|
|
47
|
+
async def on_enter(self) -> None:
|
|
48
|
+
memories = await self._memory.recall_session_context()
|
|
49
|
+
if memories is not None:
|
|
50
|
+
await self.update_instructions(self._with_memories(memories))
|
|
51
|
+
|
|
52
|
+
async def llm_node(
|
|
53
|
+
self,
|
|
54
|
+
chat_ctx: llm.ChatContext,
|
|
55
|
+
tools: list[llm.Tool],
|
|
56
|
+
model_settings: ModelSettings,
|
|
57
|
+
) -> AsyncIterable[llm.ChatChunk | str]:
|
|
58
|
+
chat_ctx = await self._ensure_session_memories(chat_ctx)
|
|
59
|
+
chat_ctx = await self._memory.inject_memories(chat_ctx)
|
|
60
|
+
async for chunk in Agent.default.llm_node(self, chat_ctx, tools, model_settings):
|
|
61
|
+
yield chunk
|
|
62
|
+
|
|
63
|
+
def _with_memories(self, memories: str) -> str:
|
|
64
|
+
return f"{self._base_instructions}\n\n{memories}"
|
|
65
|
+
|
|
66
|
+
async def _ensure_session_memories(self, chat_ctx: llm.ChatContext) -> llm.ChatContext:
|
|
67
|
+
"""Make sure this LLM call sees the session memories.
|
|
68
|
+
|
|
69
|
+
LiveKit runs ``on_enter`` in the background and doesn't hold the first reply
|
|
70
|
+
for it, so a user who speaks first can get a reply built from the instructions
|
|
71
|
+
as they were before ``on_enter`` added the memories. Wait for the (shared,
|
|
72
|
+
already running) session recall and, if the instructions here are still the
|
|
73
|
+
original ones, use the version with memories for this call.
|
|
74
|
+
"""
|
|
75
|
+
memories = await self._memory.recall_session_context()
|
|
76
|
+
if memories is None:
|
|
77
|
+
return chat_ctx
|
|
78
|
+
for i, item in enumerate(chat_ctx.items):
|
|
79
|
+
if item.type == "message" and item.role == "system" and item.text_content == self._base_instructions:
|
|
80
|
+
updated = chat_ctx.copy()
|
|
81
|
+
# copy() shares item objects with the agent's history, so replace the
|
|
82
|
+
# item rather than editing it.
|
|
83
|
+
updated.items[i] = item.model_copy(update={"content": [self._with_memories(memories)]})
|
|
84
|
+
return updated
|
|
85
|
+
return chat_ctx
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Global configuration for Hindsight-LiveKit integration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
DEFAULT_HINDSIGHT_API_URL = "https://api.hindsight.vectorize.io"
|
|
10
|
+
HINDSIGHT_API_URL_ENV = "HINDSIGHT_API_URL"
|
|
11
|
+
HINDSIGHT_API_KEY_ENV = "HINDSIGHT_API_KEY"
|
|
12
|
+
|
|
13
|
+
Budget = Literal["low", "mid", "high"]
|
|
14
|
+
TagsMatch = Literal["any", "all", "any_strict", "all_strict"]
|
|
15
|
+
|
|
16
|
+
# Voice defaults: every recall happens while a caller waits, so they favour speed.
|
|
17
|
+
# Hindsight documents "low" as the budget for fast, simple lookups.
|
|
18
|
+
DEFAULT_RECALL_BUDGET: Budget = "low"
|
|
19
|
+
DEFAULT_RECALL_MAX_TOKENS = 1024
|
|
20
|
+
DEFAULT_RECALL_TIMEOUT = 3.0
|
|
21
|
+
DEFAULT_RECALL_TAGS_MATCH: TagsMatch = "any"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class HindsightLiveKitConfig:
|
|
26
|
+
"""Connection and default settings for the LiveKit integration.
|
|
27
|
+
|
|
28
|
+
Attributes:
|
|
29
|
+
hindsight_api_url: URL of the Hindsight API server.
|
|
30
|
+
api_key: API key for Hindsight authentication.
|
|
31
|
+
recall_budget: Default recall budget level (low/mid/high).
|
|
32
|
+
recall_max_tokens: Default maximum tokens for recall results.
|
|
33
|
+
recall_timeout: Default seconds to wait for recall before answering without memories.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
hindsight_api_url: str = DEFAULT_HINDSIGHT_API_URL
|
|
37
|
+
api_key: str | None = None
|
|
38
|
+
recall_budget: Budget = DEFAULT_RECALL_BUDGET
|
|
39
|
+
recall_max_tokens: int = DEFAULT_RECALL_MAX_TOKENS
|
|
40
|
+
recall_timeout: float = DEFAULT_RECALL_TIMEOUT
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
_global_config: HindsightLiveKitConfig | None = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def configure(
|
|
47
|
+
hindsight_api_url: str | None = None,
|
|
48
|
+
api_key: str | None = None,
|
|
49
|
+
recall_budget: Budget = DEFAULT_RECALL_BUDGET,
|
|
50
|
+
recall_max_tokens: int = DEFAULT_RECALL_MAX_TOKENS,
|
|
51
|
+
recall_timeout: float = DEFAULT_RECALL_TIMEOUT,
|
|
52
|
+
) -> HindsightLiveKitConfig:
|
|
53
|
+
"""Configure Hindsight connection and default settings.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
hindsight_api_url: Hindsight API URL. Falls back to HINDSIGHT_API_URL env var,
|
|
57
|
+
then Hindsight Cloud.
|
|
58
|
+
api_key: API key. Falls back to HINDSIGHT_API_KEY env var.
|
|
59
|
+
recall_budget: Default recall budget (low/mid/high).
|
|
60
|
+
recall_max_tokens: Default max tokens for recall.
|
|
61
|
+
recall_timeout: Default recall timeout in seconds.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
The configured HindsightLiveKitConfig.
|
|
65
|
+
"""
|
|
66
|
+
global _global_config
|
|
67
|
+
|
|
68
|
+
_global_config = HindsightLiveKitConfig(
|
|
69
|
+
hindsight_api_url=hindsight_api_url or os.environ.get(HINDSIGHT_API_URL_ENV) or DEFAULT_HINDSIGHT_API_URL,
|
|
70
|
+
api_key=api_key or os.environ.get(HINDSIGHT_API_KEY_ENV),
|
|
71
|
+
recall_budget=recall_budget,
|
|
72
|
+
recall_max_tokens=recall_max_tokens,
|
|
73
|
+
recall_timeout=recall_timeout,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
return _global_config
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def get_config() -> HindsightLiveKitConfig | None:
|
|
80
|
+
"""Get the current global configuration."""
|
|
81
|
+
return _global_config
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def reset_config() -> None:
|
|
85
|
+
"""Reset global configuration to None."""
|
|
86
|
+
global _global_config
|
|
87
|
+
_global_config = None
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
"""Hindsight memory for LiveKit voice agents.
|
|
2
|
+
|
|
3
|
+
``HindsightMemory`` handles long-term memory for one ``AgentSession``:
|
|
4
|
+
|
|
5
|
+
* **Session recall** — :meth:`HindsightMemory.recall_session_context` looks up what
|
|
6
|
+
is known about the user once, when the agent starts. ``HindsightAgent`` adds it to
|
|
7
|
+
the agent's instructions. Works with STT-LLM-TTS pipelines and realtime models.
|
|
8
|
+
* **Per-turn recall** — :meth:`HindsightMemory.inject_memories` searches Hindsight
|
|
9
|
+
with the user's latest message and returns a copy of the chat context with the
|
|
10
|
+
results inserted just before that message. ``HindsightAgent`` calls it from
|
|
11
|
+
``llm_node``, which only STT-LLM-TTS pipelines use.
|
|
12
|
+
* **Retain** — :meth:`HindsightMemory.attach` listens to the session's
|
|
13
|
+
``conversation_item_added`` events and appends the conversation to one Hindsight
|
|
14
|
+
document per session (``update_mode="append"``).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import logging
|
|
21
|
+
import uuid
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from datetime import datetime, timezone
|
|
24
|
+
|
|
25
|
+
from hindsight_client import Hindsight
|
|
26
|
+
from hindsight_client_api.exceptions import NotFoundException
|
|
27
|
+
from livekit.agents import AgentSession, CloseEvent, ConversationItemAddedEvent
|
|
28
|
+
from livekit.agents.llm import ChatContext, ChatMessage
|
|
29
|
+
|
|
30
|
+
from ._client import resolve_client
|
|
31
|
+
from .config import (
|
|
32
|
+
DEFAULT_RECALL_BUDGET,
|
|
33
|
+
DEFAULT_RECALL_MAX_TOKENS,
|
|
34
|
+
DEFAULT_RECALL_TAGS_MATCH,
|
|
35
|
+
DEFAULT_RECALL_TIMEOUT,
|
|
36
|
+
Budget,
|
|
37
|
+
TagsMatch,
|
|
38
|
+
get_config,
|
|
39
|
+
)
|
|
40
|
+
from .errors import HindsightLiveKitError
|
|
41
|
+
|
|
42
|
+
logger = logging.getLogger(__name__)
|
|
43
|
+
|
|
44
|
+
_MEMORY_MARKER = "<hindsight_memories>"
|
|
45
|
+
_DEFAULT_MEMORY_PREFIX = (
|
|
46
|
+
"Relevant memories about the user from past conversations. "
|
|
47
|
+
"Use them only when they help answer, and never read this list out loud."
|
|
48
|
+
)
|
|
49
|
+
_DEFAULT_SESSION_RECALL_QUERY = "Who is this user? Their name, preferences, and important facts about them."
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class _TranscriptLine:
|
|
54
|
+
role: str
|
|
55
|
+
created_at: float
|
|
56
|
+
text: str
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _last_user_message_index(chat_ctx: ChatContext) -> int | None:
|
|
60
|
+
"""Return the index of the last user message in ``chat_ctx``, or None."""
|
|
61
|
+
for i in range(len(chat_ctx.items) - 1, -1, -1):
|
|
62
|
+
item = chat_ctx.items[i]
|
|
63
|
+
if item.type == "message" and item.role == "user":
|
|
64
|
+
return i
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _to_datetime(created_at: float) -> datetime:
|
|
69
|
+
return datetime.fromtimestamp(created_at, tz=timezone.utc)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class HindsightMemory:
|
|
73
|
+
"""Persistent Hindsight memory for a single LiveKit ``AgentSession``.
|
|
74
|
+
|
|
75
|
+
Create one per session, attach it to the session so the conversation is
|
|
76
|
+
retained, and give it to a :class:`~hindsight_livekit.HindsightAgent` so
|
|
77
|
+
memories are recalled::
|
|
78
|
+
|
|
79
|
+
memory = HindsightMemory(bank_id="user-123", document_id=ctx.room.name)
|
|
80
|
+
memory.attach(session)
|
|
81
|
+
ctx.add_shutdown_callback(memory.aclose)
|
|
82
|
+
await session.start(agent=HindsightAgent(memory=memory, instructions="..."), room=ctx.room)
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
bank_id: Hindsight memory bank to read from and write to (usually one per end user).
|
|
86
|
+
client: Pre-configured Hindsight client. If omitted, one is created from
|
|
87
|
+
``hindsight_api_url``/``api_key``, then ``configure()``, then the
|
|
88
|
+
``HINDSIGHT_API_URL``/``HINDSIGHT_API_KEY`` env vars.
|
|
89
|
+
hindsight_api_url: API URL (used if no client provided).
|
|
90
|
+
api_key: API key (used if no client provided).
|
|
91
|
+
document_id: Hindsight document that this session's transcript is appended to.
|
|
92
|
+
Defaults to a random ``livekit-<uuid>`` id; the room name is a good choice.
|
|
93
|
+
recall_budget: Recall budget level — ``"low"``, ``"mid"``, ``"high"``.
|
|
94
|
+
Defaults to ``"low"``, which Hindsight recommends for fast lookups.
|
|
95
|
+
recall_max_tokens: Maximum tokens of memories injected per recall.
|
|
96
|
+
recall_timeout: Seconds to wait for recall. On timeout the agent continues
|
|
97
|
+
without memories instead of leaving the caller in silence.
|
|
98
|
+
recall_types: Memory types to search (``"world"``, ``"experience"``,
|
|
99
|
+
``"observation"``). ``None`` searches all of them.
|
|
100
|
+
tags: Tags attached to retained content.
|
|
101
|
+
recall_tags: Tags to filter recall by. Defaults to no filter.
|
|
102
|
+
recall_tags_match: How ``recall_tags`` are matched (any/all/any_strict/all_strict).
|
|
103
|
+
retain_context: Short label describing the source, passed to Hindsight's
|
|
104
|
+
fact extraction as ``context``.
|
|
105
|
+
user_name: Speaker label for the user in the retained transcript.
|
|
106
|
+
agent_name: Speaker label for the agent in the retained transcript.
|
|
107
|
+
enable_session_recall: Recall what is known about the user once, when the
|
|
108
|
+
agent starts, and add it to the agent's instructions.
|
|
109
|
+
session_recall_query: Query used for session recall.
|
|
110
|
+
enable_recall: Recall memories relevant to each user message before the
|
|
111
|
+
LLM replies (STT-LLM-TTS pipelines only).
|
|
112
|
+
enable_retain: Store the conversation as it happens.
|
|
113
|
+
memory_prefix: Instruction placed above recalled memories.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
def __init__(
|
|
117
|
+
self,
|
|
118
|
+
bank_id: str,
|
|
119
|
+
*,
|
|
120
|
+
client: Hindsight | None = None,
|
|
121
|
+
hindsight_api_url: str | None = None,
|
|
122
|
+
api_key: str | None = None,
|
|
123
|
+
document_id: str | None = None,
|
|
124
|
+
recall_budget: Budget | None = None,
|
|
125
|
+
recall_max_tokens: int | None = None,
|
|
126
|
+
recall_timeout: float | None = None,
|
|
127
|
+
recall_types: list[str] | None = None,
|
|
128
|
+
tags: list[str] | None = None,
|
|
129
|
+
recall_tags: list[str] | None = None,
|
|
130
|
+
recall_tags_match: TagsMatch = DEFAULT_RECALL_TAGS_MATCH,
|
|
131
|
+
retain_context: str = "voice conversation",
|
|
132
|
+
user_name: str = "User",
|
|
133
|
+
agent_name: str = "Assistant",
|
|
134
|
+
enable_session_recall: bool = True,
|
|
135
|
+
session_recall_query: str = _DEFAULT_SESSION_RECALL_QUERY,
|
|
136
|
+
enable_recall: bool = True,
|
|
137
|
+
enable_retain: bool = True,
|
|
138
|
+
memory_prefix: str = _DEFAULT_MEMORY_PREFIX,
|
|
139
|
+
) -> None:
|
|
140
|
+
self._bank_id = bank_id
|
|
141
|
+
self._owns_client = client is None
|
|
142
|
+
self._client = resolve_client(client, hindsight_api_url, api_key)
|
|
143
|
+
config = get_config()
|
|
144
|
+
self._document_id = document_id or f"livekit-{uuid.uuid4().hex}"
|
|
145
|
+
self._recall_budget: Budget = recall_budget or (config.recall_budget if config else DEFAULT_RECALL_BUDGET)
|
|
146
|
+
self._recall_max_tokens = recall_max_tokens or (
|
|
147
|
+
config.recall_max_tokens if config else DEFAULT_RECALL_MAX_TOKENS
|
|
148
|
+
)
|
|
149
|
+
self._recall_timeout = recall_timeout or (config.recall_timeout if config else DEFAULT_RECALL_TIMEOUT)
|
|
150
|
+
self._recall_types = recall_types
|
|
151
|
+
self._tags = tags
|
|
152
|
+
self._recall_tags = recall_tags
|
|
153
|
+
self._recall_tags_match = recall_tags_match
|
|
154
|
+
self._retain_context = retain_context
|
|
155
|
+
self._user_name = user_name
|
|
156
|
+
self._agent_name = agent_name
|
|
157
|
+
self._enable_session_recall = enable_session_recall
|
|
158
|
+
self._session_recall_query = session_recall_query
|
|
159
|
+
self._enable_recall = enable_recall
|
|
160
|
+
self._enable_retain = enable_retain
|
|
161
|
+
self._memory_prefix = memory_prefix
|
|
162
|
+
|
|
163
|
+
self._session: AgentSession | None = None
|
|
164
|
+
# Transcript lines not yet sent to Hindsight. See _on_conversation_item_added
|
|
165
|
+
# for when they are flushed.
|
|
166
|
+
self._pending: list[_TranscriptLine] = []
|
|
167
|
+
# Appends to one document must not overlap (Hindsight rejects concurrent
|
|
168
|
+
# appends), so they run one at a time, in order. An asyncio.Lock is safe here
|
|
169
|
+
# because a HindsightMemory belongs to one AgentSession, which runs on one loop.
|
|
170
|
+
self._retain_lock = asyncio.Lock()
|
|
171
|
+
self._retain_tasks: set[asyncio.Task[None]] = set()
|
|
172
|
+
# Recall result for the current user message, shared by every LLM call that
|
|
173
|
+
# answers it (preemptive generation, tool-call follow-ups). Keyed on message
|
|
174
|
+
# id and text, so a revised transcript gets a fresh recall.
|
|
175
|
+
self._recall_message_id: str | None = None
|
|
176
|
+
self._recall_query: str | None = None
|
|
177
|
+
self._recall_task: asyncio.Task[str | None] | None = None
|
|
178
|
+
self._session_recall_task: asyncio.Task[str | None] | None = None
|
|
179
|
+
self._closed = False
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def bank_id(self) -> str:
|
|
183
|
+
return self._bank_id
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def document_id(self) -> str:
|
|
187
|
+
return self._document_id
|
|
188
|
+
|
|
189
|
+
@property
|
|
190
|
+
def client(self) -> Hindsight:
|
|
191
|
+
return self._client
|
|
192
|
+
|
|
193
|
+
# ------------------------------------------------------------------
|
|
194
|
+
# Recall
|
|
195
|
+
# ------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
async def recall(self, query: str) -> str | None:
|
|
198
|
+
"""Search Hindsight and return a formatted memory block, or None.
|
|
199
|
+
|
|
200
|
+
Never raises: errors and timeouts are logged and return None so the
|
|
201
|
+
conversation continues without memories.
|
|
202
|
+
"""
|
|
203
|
+
try:
|
|
204
|
+
response = await asyncio.wait_for(
|
|
205
|
+
self._client.arecall(
|
|
206
|
+
bank_id=self._bank_id,
|
|
207
|
+
query=query,
|
|
208
|
+
types=self._recall_types,
|
|
209
|
+
budget=self._recall_budget,
|
|
210
|
+
max_tokens=self._recall_max_tokens,
|
|
211
|
+
tags=self._recall_tags,
|
|
212
|
+
tags_match=self._recall_tags_match,
|
|
213
|
+
),
|
|
214
|
+
timeout=self._recall_timeout,
|
|
215
|
+
)
|
|
216
|
+
except NotFoundException:
|
|
217
|
+
# A new user's bank doesn't exist until the first retain creates it.
|
|
218
|
+
logger.debug(f"Hindsight bank {self._bank_id!r} not found yet (no memories to recall)")
|
|
219
|
+
return None
|
|
220
|
+
except asyncio.TimeoutError:
|
|
221
|
+
logger.warning(f"Hindsight recall timed out after {self._recall_timeout}s (continuing without memories)")
|
|
222
|
+
return None
|
|
223
|
+
except Exception as e:
|
|
224
|
+
logger.warning(f"Hindsight recall failed (continuing without memories): {e}")
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
if not response.results:
|
|
228
|
+
return None
|
|
229
|
+
lines = [_MEMORY_MARKER, self._memory_prefix]
|
|
230
|
+
for i, result in enumerate(response.results, 1):
|
|
231
|
+
lines.append(f"{i}. {result.text}")
|
|
232
|
+
lines.append("</hindsight_memories>")
|
|
233
|
+
return "\n".join(lines)
|
|
234
|
+
|
|
235
|
+
async def recall_session_context(self) -> str | None:
|
|
236
|
+
"""Recall what is known about the user, for the start of a session.
|
|
237
|
+
|
|
238
|
+
Returns a formatted memory block, or None if session recall is disabled or
|
|
239
|
+
nothing is found. The lookup runs once per session (``attach()`` starts it
|
|
240
|
+
early) and later calls return the same result. Never raises.
|
|
241
|
+
"""
|
|
242
|
+
if not self._enable_session_recall:
|
|
243
|
+
return None
|
|
244
|
+
return await asyncio.shield(self._start_session_recall())
|
|
245
|
+
|
|
246
|
+
def _start_session_recall(self) -> asyncio.Task[str | None]:
|
|
247
|
+
if self._session_recall_task is None:
|
|
248
|
+
self._session_recall_task = asyncio.create_task(self.recall(self._session_recall_query))
|
|
249
|
+
return self._session_recall_task
|
|
250
|
+
|
|
251
|
+
async def inject_memories(self, chat_ctx: ChatContext) -> ChatContext:
|
|
252
|
+
"""Return ``chat_ctx`` with recalled memories inserted before the last user message.
|
|
253
|
+
|
|
254
|
+
The input context is not modified, so memories never enter the session's
|
|
255
|
+
stored history. Recall runs at most once per user message. If there is no
|
|
256
|
+
user message or nothing relevant is found, ``chat_ctx`` is returned as is.
|
|
257
|
+
"""
|
|
258
|
+
if not self._enable_recall:
|
|
259
|
+
return chat_ctx
|
|
260
|
+
index = _last_user_message_index(chat_ctx)
|
|
261
|
+
if index is None:
|
|
262
|
+
return chat_ctx
|
|
263
|
+
user_message = chat_ctx.items[index]
|
|
264
|
+
query = user_message.text_content
|
|
265
|
+
if not query or not query.strip():
|
|
266
|
+
return chat_ctx
|
|
267
|
+
|
|
268
|
+
if self._recall_task is None or self._recall_message_id != user_message.id or self._recall_query != query:
|
|
269
|
+
self._recall_message_id = user_message.id
|
|
270
|
+
self._recall_query = query
|
|
271
|
+
self._recall_task = asyncio.create_task(self.recall(query))
|
|
272
|
+
# Shield so a cancelled generation (e.g. a discarded preemptive reply)
|
|
273
|
+
# doesn't cancel the recall the next generation is waiting on.
|
|
274
|
+
memories = await asyncio.shield(self._recall_task)
|
|
275
|
+
if memories is None:
|
|
276
|
+
return chat_ctx
|
|
277
|
+
|
|
278
|
+
# LiveKit's RAG example adds looked-up context as an assistant message
|
|
279
|
+
# before the user's turn (docs.livekit.io/agents/build/external-data).
|
|
280
|
+
enriched = chat_ctx.copy()
|
|
281
|
+
enriched.items.insert(
|
|
282
|
+
index,
|
|
283
|
+
ChatMessage(
|
|
284
|
+
role="assistant",
|
|
285
|
+
content=[memories],
|
|
286
|
+
created_at=user_message.created_at - 1e-6,
|
|
287
|
+
),
|
|
288
|
+
)
|
|
289
|
+
return enriched
|
|
290
|
+
|
|
291
|
+
# ------------------------------------------------------------------
|
|
292
|
+
# Retain
|
|
293
|
+
# ------------------------------------------------------------------
|
|
294
|
+
|
|
295
|
+
def attach(self, session: AgentSession) -> None:
|
|
296
|
+
"""Retain this session's conversation in Hindsight as it happens.
|
|
297
|
+
|
|
298
|
+
Call once, before ``session.start()``. The transcript is appended to the
|
|
299
|
+
Hindsight document ``document_id`` in the background. Also starts the
|
|
300
|
+
session recall early, so it is usually ready by the time the agent starts.
|
|
301
|
+
"""
|
|
302
|
+
if self._session is session:
|
|
303
|
+
return
|
|
304
|
+
if self._session is not None:
|
|
305
|
+
raise HindsightLiveKitError("HindsightMemory is already attached to another AgentSession")
|
|
306
|
+
self._session = session
|
|
307
|
+
session.on("conversation_item_added", self._on_conversation_item_added)
|
|
308
|
+
session.on("close", self._on_session_close)
|
|
309
|
+
# LiveKit runs Agent.on_enter in the background and doesn't hold the first
|
|
310
|
+
# reply for it, so a slow session recall could miss the first turn. Starting
|
|
311
|
+
# it here overlaps it with the room and model connecting in session.start().
|
|
312
|
+
if self._enable_session_recall:
|
|
313
|
+
self._start_session_recall()
|
|
314
|
+
|
|
315
|
+
def _on_conversation_item_added(self, event: ConversationItemAddedEvent) -> None:
|
|
316
|
+
if not self._enable_retain or self._closed:
|
|
317
|
+
return
|
|
318
|
+
item = event.item
|
|
319
|
+
if item.type != "message" or item.role not in ("user", "assistant"):
|
|
320
|
+
return
|
|
321
|
+
text = item.text_content
|
|
322
|
+
if not text or not text.strip():
|
|
323
|
+
return
|
|
324
|
+
|
|
325
|
+
self._pending.append(_TranscriptLine(role=item.role, created_at=item.created_at, text=text.strip()))
|
|
326
|
+
# Flush once the buffer holds both sides of the conversation. With an
|
|
327
|
+
# STT-LLM-TTS pipeline the user's message is always added before the reply.
|
|
328
|
+
# Realtime models may add the user's transcript only after the reply it
|
|
329
|
+
# prompted, so flushing on every reply would store them out of order; waiting
|
|
330
|
+
# for both roles and sorting by created_at keeps the transcript in order.
|
|
331
|
+
roles = {line.role for line in self._pending}
|
|
332
|
+
if "user" in roles and "assistant" in roles:
|
|
333
|
+
self._schedule_retain()
|
|
334
|
+
|
|
335
|
+
def _on_session_close(self, event: CloseEvent) -> None:
|
|
336
|
+
self._schedule_retain()
|
|
337
|
+
|
|
338
|
+
def _schedule_retain(self) -> None:
|
|
339
|
+
if not self._pending:
|
|
340
|
+
return
|
|
341
|
+
lines = sorted(self._pending, key=lambda line: line.created_at)
|
|
342
|
+
self._pending = []
|
|
343
|
+
content = "\n".join(self._format_line(line) for line in lines)
|
|
344
|
+
task = asyncio.create_task(self._retain(content, _to_datetime(lines[-1].created_at)))
|
|
345
|
+
self._retain_tasks.add(task)
|
|
346
|
+
task.add_done_callback(self._retain_tasks.discard)
|
|
347
|
+
|
|
348
|
+
def _format_line(self, line: _TranscriptLine) -> str:
|
|
349
|
+
# "Name (timestamp): text" is the format Hindsight's docs use for conversations,
|
|
350
|
+
# so facts are attributed to the right speaker and time.
|
|
351
|
+
speaker = self._user_name if line.role == "user" else self._agent_name
|
|
352
|
+
timestamp = _to_datetime(line.created_at).isoformat(timespec="seconds")
|
|
353
|
+
return f"{speaker} ({timestamp}): {line.text}"
|
|
354
|
+
|
|
355
|
+
async def _retain(self, content: str, timestamp: datetime) -> None:
|
|
356
|
+
"""Append ``content`` to this session's document (errors are logged and swallowed)."""
|
|
357
|
+
async with self._retain_lock:
|
|
358
|
+
try:
|
|
359
|
+
await self._client.aretain(
|
|
360
|
+
bank_id=self._bank_id,
|
|
361
|
+
content=content,
|
|
362
|
+
timestamp=timestamp,
|
|
363
|
+
context=self._retain_context,
|
|
364
|
+
document_id=self._document_id,
|
|
365
|
+
tags=self._tags,
|
|
366
|
+
update_mode="append",
|
|
367
|
+
)
|
|
368
|
+
except Exception as e:
|
|
369
|
+
logger.warning(f"Hindsight retain failed: {e}")
|
|
370
|
+
|
|
371
|
+
async def flush(self) -> None:
|
|
372
|
+
"""Send any buffered transcript lines and wait for in-flight retains to finish."""
|
|
373
|
+
self._schedule_retain()
|
|
374
|
+
if self._retain_tasks:
|
|
375
|
+
await asyncio.gather(*self._retain_tasks, return_exceptions=True)
|
|
376
|
+
|
|
377
|
+
async def aclose(self) -> None:
|
|
378
|
+
"""Flush pending retains and close the client if this object created it.
|
|
379
|
+
|
|
380
|
+
Register it with ``ctx.add_shutdown_callback(memory.aclose)`` so the end
|
|
381
|
+
of the call is stored before the job exits.
|
|
382
|
+
"""
|
|
383
|
+
if self._closed:
|
|
384
|
+
return
|
|
385
|
+
await self.flush()
|
|
386
|
+
self._closed = True
|
|
387
|
+
for task in (self._recall_task, self._session_recall_task):
|
|
388
|
+
if task is not None and not task.done():
|
|
389
|
+
task.cancel()
|
|
390
|
+
if self._owns_client:
|
|
391
|
+
await self._client.aclose()
|
|
File without changes
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""LiveKit function tools for Hindsight memory operations.
|
|
2
|
+
|
|
3
|
+
Provides a factory that creates LiveKit ``FunctionTool`` instances backed by
|
|
4
|
+
Hindsight's retain/recall/reflect APIs. Pass them to ``Agent(tools=[...])``.
|
|
5
|
+
They work with STT-LLM-TTS pipelines and realtime models.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
|
|
12
|
+
from hindsight_client import Hindsight
|
|
13
|
+
from livekit.agents import RunContext, ToolError, function_tool
|
|
14
|
+
from livekit.agents.llm import FunctionTool
|
|
15
|
+
|
|
16
|
+
from ._client import resolve_client
|
|
17
|
+
from .config import (
|
|
18
|
+
DEFAULT_RECALL_BUDGET,
|
|
19
|
+
DEFAULT_RECALL_MAX_TOKENS,
|
|
20
|
+
DEFAULT_RECALL_TAGS_MATCH,
|
|
21
|
+
Budget,
|
|
22
|
+
TagsMatch,
|
|
23
|
+
get_config,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def create_hindsight_tools(
|
|
30
|
+
*,
|
|
31
|
+
bank_id: str,
|
|
32
|
+
client: Hindsight | None = None,
|
|
33
|
+
hindsight_api_url: str | None = None,
|
|
34
|
+
api_key: str | None = None,
|
|
35
|
+
budget: Budget | None = None,
|
|
36
|
+
max_tokens: int | None = None,
|
|
37
|
+
tags: list[str] | None = None,
|
|
38
|
+
recall_tags: list[str] | None = None,
|
|
39
|
+
recall_tags_match: TagsMatch = DEFAULT_RECALL_TAGS_MATCH,
|
|
40
|
+
recall_types: list[str] | None = None,
|
|
41
|
+
retain_context: str = "voice conversation",
|
|
42
|
+
include_retain: bool = True,
|
|
43
|
+
include_recall: bool = True,
|
|
44
|
+
include_reflect: bool = True,
|
|
45
|
+
) -> list[FunctionTool]:
|
|
46
|
+
"""Create Hindsight memory tools for a LiveKit agent.
|
|
47
|
+
|
|
48
|
+
Returns ``FunctionTool`` instances for ``Agent(tools=[...])``. With a
|
|
49
|
+
:class:`~hindsight_livekit.HindsightMemory`, share its client and bank::
|
|
50
|
+
|
|
51
|
+
tools = create_hindsight_tools(bank_id=memory.bank_id, client=memory.client)
|
|
52
|
+
|
|
53
|
+
Errors are raised as LiveKit ``ToolError`` so the model hears what went wrong
|
|
54
|
+
and can tell the caller, instead of the call failing.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
bank_id: The Hindsight memory bank to operate on.
|
|
58
|
+
client: Pre-configured Hindsight client (preferred).
|
|
59
|
+
hindsight_api_url: API URL (used if no client provided).
|
|
60
|
+
api_key: API key (used if no client provided).
|
|
61
|
+
budget: Recall/reflect budget level (low/mid/high). Defaults to ``"low"``.
|
|
62
|
+
max_tokens: Maximum tokens for recall and reflect results.
|
|
63
|
+
tags: Tags applied when storing memories via retain.
|
|
64
|
+
recall_tags: Tags to filter by when searching memories.
|
|
65
|
+
recall_tags_match: Tag matching mode (any/all/any_strict/all_strict).
|
|
66
|
+
recall_types: Fact types to search (world, experience, observation).
|
|
67
|
+
retain_context: Source label passed to Hindsight when storing memories.
|
|
68
|
+
include_retain: Include the ``hindsight_retain`` (store) tool.
|
|
69
|
+
include_recall: Include the ``hindsight_recall`` (search) tool.
|
|
70
|
+
include_reflect: Include the ``hindsight_reflect`` (synthesize) tool. Reflect
|
|
71
|
+
runs an LLM on the Hindsight server, so it is the slowest of the three.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
List of LiveKit FunctionTool instances.
|
|
75
|
+
"""
|
|
76
|
+
resolved_client = resolve_client(client, hindsight_api_url, api_key)
|
|
77
|
+
config = get_config()
|
|
78
|
+
effective_budget: Budget = budget or (config.recall_budget if config else DEFAULT_RECALL_BUDGET)
|
|
79
|
+
effective_max_tokens = max_tokens or (config.recall_max_tokens if config else DEFAULT_RECALL_MAX_TOKENS)
|
|
80
|
+
|
|
81
|
+
tools: list[FunctionTool] = []
|
|
82
|
+
|
|
83
|
+
if include_retain:
|
|
84
|
+
|
|
85
|
+
@function_tool
|
|
86
|
+
async def hindsight_retain(context: RunContext, content: str) -> str:
|
|
87
|
+
"""Store information in long-term memory so it is remembered in future calls.
|
|
88
|
+
|
|
89
|
+
Use this when the user shares something worth remembering, such as a
|
|
90
|
+
preference, a plan, or a fact about themselves.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
content: The information to store, as a complete sentence.
|
|
94
|
+
"""
|
|
95
|
+
try:
|
|
96
|
+
await resolved_client.aretain(
|
|
97
|
+
bank_id=bank_id,
|
|
98
|
+
content=content,
|
|
99
|
+
context=retain_context,
|
|
100
|
+
tags=tags,
|
|
101
|
+
)
|
|
102
|
+
except Exception as e:
|
|
103
|
+
logger.error(f"Hindsight retain failed: {e}")
|
|
104
|
+
raise ToolError("Could not save that to memory right now.") from e
|
|
105
|
+
return "Saved to memory."
|
|
106
|
+
|
|
107
|
+
tools.append(hindsight_retain)
|
|
108
|
+
|
|
109
|
+
if include_recall:
|
|
110
|
+
|
|
111
|
+
@function_tool
|
|
112
|
+
async def hindsight_recall(context: RunContext, query: str) -> str:
|
|
113
|
+
"""Search long-term memory for things the user said in earlier conversations.
|
|
114
|
+
|
|
115
|
+
Use this when the user refers to something from the past, or when knowing
|
|
116
|
+
their preferences or history would help you answer.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
query: What to look for, as a short question or phrase.
|
|
120
|
+
"""
|
|
121
|
+
try:
|
|
122
|
+
response = await resolved_client.arecall(
|
|
123
|
+
bank_id=bank_id,
|
|
124
|
+
query=query,
|
|
125
|
+
types=recall_types,
|
|
126
|
+
budget=effective_budget,
|
|
127
|
+
max_tokens=effective_max_tokens,
|
|
128
|
+
tags=recall_tags,
|
|
129
|
+
tags_match=recall_tags_match,
|
|
130
|
+
)
|
|
131
|
+
except Exception as e:
|
|
132
|
+
logger.error(f"Hindsight recall failed: {e}")
|
|
133
|
+
raise ToolError("Could not search memory right now.") from e
|
|
134
|
+
if not response.results:
|
|
135
|
+
return "No relevant memories found."
|
|
136
|
+
return "\n".join(f"{i}. {result.text}" for i, result in enumerate(response.results, 1))
|
|
137
|
+
|
|
138
|
+
tools.append(hindsight_recall)
|
|
139
|
+
|
|
140
|
+
if include_reflect:
|
|
141
|
+
|
|
142
|
+
@function_tool
|
|
143
|
+
async def hindsight_reflect(context: RunContext, query: str) -> str:
|
|
144
|
+
"""Answer a question by reasoning over everything in long-term memory.
|
|
145
|
+
|
|
146
|
+
Use this for broader questions, like summarising what you know about the
|
|
147
|
+
user, rather than looking up a single fact.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
query: The question to answer from memory.
|
|
151
|
+
"""
|
|
152
|
+
try:
|
|
153
|
+
response = await resolved_client.areflect(
|
|
154
|
+
bank_id=bank_id,
|
|
155
|
+
query=query,
|
|
156
|
+
budget=effective_budget,
|
|
157
|
+
max_tokens=effective_max_tokens,
|
|
158
|
+
tags=recall_tags,
|
|
159
|
+
tags_match=recall_tags_match,
|
|
160
|
+
)
|
|
161
|
+
except Exception as e:
|
|
162
|
+
logger.error(f"Hindsight reflect failed: {e}")
|
|
163
|
+
raise ToolError("Could not reflect on memory right now.") from e
|
|
164
|
+
return response.text or "No relevant memories found."
|
|
165
|
+
|
|
166
|
+
tools.append(hindsight_reflect)
|
|
167
|
+
|
|
168
|
+
return tools
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: hindsight-livekit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: LiveKit Agents integration for Hindsight - persistent memory for voice AI agents
|
|
5
|
+
Project-URL: Homepage, https://github.com/namandidthis/hindsight-livekit
|
|
6
|
+
Project-URL: Repository, https://github.com/namandidthis/hindsight-livekit
|
|
7
|
+
Project-URL: Issues, https://github.com/namandidthis/hindsight-livekit/issues
|
|
8
|
+
Project-URL: Hindsight, https://github.com/vectorize-io/hindsight
|
|
9
|
+
Author-email: Naman <naman6176@gmail.com>
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: agents,ai,hindsight,livekit,memory,realtime,voice
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
22
|
+
Requires-Python: >=3.11
|
|
23
|
+
Requires-Dist: hindsight-client>=0.6.0
|
|
24
|
+
Requires-Dist: livekit-agents<2.0,>=1.2
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# Hindsight LiveKit Integration
|
|
28
|
+
|
|
29
|
+
Persistent long-term memory for [LiveKit Agents](https://github.com/livekit/agents) voice AI via [Hindsight](https://vectorize.io/hindsight). Your agent knows what it learned in earlier calls, and stores each new conversation as it happens. Works with STT-LLM-TTS pipelines and realtime (speech-to-speech) models.
|
|
30
|
+
|
|
31
|
+
## Quick Start
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install hindsight-livekit
|
|
35
|
+
# or the latest from GitHub:
|
|
36
|
+
pip install git+https://github.com/namandidthis/hindsight-livekit
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
> ✨ **Recommended: [Hindsight Cloud](https://ui.hindsight.vectorize.io)** — free tier, no self-hosting required. Sign up and grab an API key in under a minute.
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from livekit.agents import AgentServer, AgentSession, JobContext, inference
|
|
43
|
+
from hindsight_livekit import HindsightAgent, HindsightMemory
|
|
44
|
+
|
|
45
|
+
server = AgentServer()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@server.rtc_session()
|
|
49
|
+
async def entrypoint(ctx: JobContext):
|
|
50
|
+
session = AgentSession(stt=..., tts=...)
|
|
51
|
+
|
|
52
|
+
memory = HindsightMemory(
|
|
53
|
+
bank_id="user-123", # one memory bank per end user
|
|
54
|
+
document_id=ctx.room.name, # one transcript per call
|
|
55
|
+
hindsight_api_url="https://api.hindsight.vectorize.io",
|
|
56
|
+
api_key="hsk_...", # or set HINDSIGHT_API_KEY
|
|
57
|
+
)
|
|
58
|
+
memory.attach(session) # retain the conversation
|
|
59
|
+
ctx.add_shutdown_callback(memory.aclose) # store the end of the call on hang-up
|
|
60
|
+
|
|
61
|
+
await session.start(
|
|
62
|
+
agent=HindsightAgent(
|
|
63
|
+
memory=memory,
|
|
64
|
+
llm=inference.LLM(model="google/gemma-4-31b-it"),
|
|
65
|
+
instructions="You are a friendly voice assistant.",
|
|
66
|
+
),
|
|
67
|
+
room=ctx.room,
|
|
68
|
+
)
|
|
69
|
+
await ctx.connect()
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`HindsightAgent` is a regular LiveKit `Agent`: subclass it, add `@function_tool`s, override `on_enter` (call `await super().on_enter()` first), and so on.
|
|
73
|
+
|
|
74
|
+
Runnable agents built on [agent-starter-python](https://github.com/livekit-examples/agent-starter-python):
|
|
75
|
+
|
|
76
|
+
- [`examples/voice_agent.py`](./examples/voice_agent.py) — STT-LLM-TTS pipeline
|
|
77
|
+
- [`examples/realtime_agent.py`](./examples/realtime_agent.py) — realtime model with memory tools
|
|
78
|
+
|
|
79
|
+
### Self-hosting (local development)
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
docker run --rm -p 8888:8888 -e HINDSIGHT_API_LLM_API_KEY=$OPENAI_API_KEY ghcr.io/vectorize-io/hindsight:latest
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
memory = HindsightMemory(bank_id="user-123", hindsight_api_url="http://localhost:8888")
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
See the [Hindsight installation guide](https://hindsight.vectorize.io/developer/installation) for other options. LiveKit itself can be [self-hosted](https://docs.livekit.io/transport/self-hosting/local/) too; the integration works the same either way.
|
|
90
|
+
|
|
91
|
+
## How It Works
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
memory.attach(session)
|
|
95
|
+
└─ Session recall starts: "who is this user?" (overlaps with session.start)
|
|
96
|
+
|
|
97
|
+
Agent starts (on_enter)
|
|
98
|
+
└─ Known facts about the user are added to the agent's instructions
|
|
99
|
+
|
|
100
|
+
Before each LLM reply (llm_node) — STT-LLM-TTS pipelines only
|
|
101
|
+
└─ Recall memories relevant to the user's latest message
|
|
102
|
+
└─ Insert them before that message, in a copy of the chat context
|
|
103
|
+
|
|
104
|
+
As the conversation happens (conversation_item_added)
|
|
105
|
+
└─ Append "User (time): …" / "Assistant (time): …" lines to the call's Hindsight document
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
| | STT-LLM-TTS pipeline | Realtime model |
|
|
109
|
+
|---|---|---|
|
|
110
|
+
| Recall when the agent starts | ✅ | ✅ |
|
|
111
|
+
| Recall before each reply | ✅ | — (no step before a realtime reply; use the tools) |
|
|
112
|
+
| Memory tools (`create_hindsight_tools`) | ✅ | ✅ |
|
|
113
|
+
| Retain the conversation | ✅ | ✅ (needs the model's input audio transcription for the user's side) |
|
|
114
|
+
|
|
115
|
+
**Session recall** runs once per call. `attach()` starts it, so it overlaps with LiveKit connecting the room and the model, and `HindsightAgent.on_enter` adds the result to the instructions. LiveKit runs `on_enter` in the background, so with a pipeline the first `llm_node` call also waits for the lookup and uses the instructions with memories, even if the user speaks first. A realtime model has no such step: a reply that starts before the lookup finishes (bounded by `recall_timeout`) won't include those memories, which is rare in practice since the lookup starts before the call connects.
|
|
116
|
+
|
|
117
|
+
**Per-turn recall** runs in [`llm_node`](https://docs.livekit.io/agents/build/nodes/), the step every LLM call in a pipeline goes through. That means:
|
|
118
|
+
|
|
119
|
+
- It works for spoken turns, chat text input and `session.generate_reply(user_input=...)`. The `on_user_turn_completed` hook only runs for spoken turns.
|
|
120
|
+
- It works with [preemptive generation](https://docs.livekit.io/agents/build/audio/#preemptive-generation): recall starts while LiveKit is still confirming the end of the user's turn, and the preemptive reply is kept. Editing the context in `on_user_turn_completed` would make LiveKit discard it.
|
|
121
|
+
- Memories are only added to a copy of the context for that call, so they never pile up in the session history. When the LLM calls a tool, the follow-up call reuses the same recall result.
|
|
122
|
+
|
|
123
|
+
As in LiveKit's [RAG example](https://docs.livekit.io/agents/build/external-data/), per-turn memories are added as an `assistant` message just before the user's message, wrapped in `<hindsight_memories>` tags with a note telling the model not to read them out.
|
|
124
|
+
|
|
125
|
+
**Retain** follows Hindsight's [guidance for conversations](https://hindsight.vectorize.io/developer/api/retain#retaining-a-conversation): each call is one document (`document_id`), written as `Name (timestamp): text` lines, grown with [`update_mode="append"`](https://hindsight.vectorize.io/developer/api/retain#update_mode) so Hindsight only extracts facts from the new part. Lines are sent once both the user and the agent have spoken, sorted by time, because realtime models can deliver the user's transcript after the reply it prompted. Appends are sent one at a time, in order, in the background, so they never delay a reply. `aclose()` sends whatever is left when the call ends.
|
|
126
|
+
|
|
127
|
+
**Failures never break the call.** If recall fails or takes longer than `recall_timeout`, the agent carries on without memories. If retain fails, the error is logged.
|
|
128
|
+
|
|
129
|
+
## Memory Tools
|
|
130
|
+
|
|
131
|
+
`create_hindsight_tools()` gives the model `hindsight_retain`, `hindsight_recall` and `hindsight_reflect` tools, like the other Hindsight framework integrations. They are the way for a realtime model to look things up mid-call, and work with pipelines too:
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
from hindsight_livekit import HindsightAgent, create_hindsight_tools
|
|
135
|
+
|
|
136
|
+
agent = HindsightAgent(
|
|
137
|
+
memory=memory,
|
|
138
|
+
llm=inference.RealtimeModel(model="openai/gpt-realtime-mini"),
|
|
139
|
+
tools=create_hindsight_tools(bank_id=memory.bank_id, client=memory.client, include_reflect=False),
|
|
140
|
+
instructions=(
|
|
141
|
+
"You have long-term memory of past calls. When the user asks about themselves, "
|
|
142
|
+
"their past, or their preferences, call hindsight_recall first. When they share "
|
|
143
|
+
"something worth remembering, call hindsight_retain."
|
|
144
|
+
),
|
|
145
|
+
)
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Tell the model when to use the tools, as above: models call them far more reliably with explicit guidance. `hindsight_reflect` runs an LLM on the Hindsight server, so it is the slowest; leave it out for snappier calls. Tool errors are raised as LiveKit `ToolError`, so the model hears what went wrong and can tell the caller.
|
|
149
|
+
|
|
150
|
+
## Latency
|
|
151
|
+
|
|
152
|
+
Recall happens while a caller waits, so the defaults are tuned for voice:
|
|
153
|
+
|
|
154
|
+
- `recall_budget="low"`: Hindsight's [fast lookup](https://hindsight.vectorize.io/developer/api/recall#budget) setting.
|
|
155
|
+
- `recall_max_tokens=1024`: keeps the prompt small.
|
|
156
|
+
- `recall_timeout=3.0`: the longest a reply waits on memory before going ahead without it.
|
|
157
|
+
|
|
158
|
+
With preemptive generation turned on, most per-turn recall time overlaps with end-of-turn detection.
|
|
159
|
+
|
|
160
|
+
## Configuration
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
HindsightMemory(
|
|
164
|
+
bank_id="user-123", # Required: memory bank to use
|
|
165
|
+
client=None, # Pre-built hindsight_client.Hindsight (optional)
|
|
166
|
+
hindsight_api_url="...", # Defaults to HINDSIGHT_API_URL, then Hindsight Cloud
|
|
167
|
+
api_key="hsk_...", # Defaults to HINDSIGHT_API_KEY
|
|
168
|
+
document_id=None, # Defaults to a random "livekit-<uuid>"
|
|
169
|
+
recall_budget="low", # "low", "mid", or "high"
|
|
170
|
+
recall_max_tokens=1024, # Max tokens of memories per recall
|
|
171
|
+
recall_timeout=3.0, # Seconds before carrying on without memories
|
|
172
|
+
recall_types=None, # e.g. ["world", "experience"]; None = all
|
|
173
|
+
tags=None, # Tags for retained content
|
|
174
|
+
recall_tags=None, # Tags to filter recall by
|
|
175
|
+
recall_tags_match="any", # "any", "all", "any_strict", "all_strict"
|
|
176
|
+
retain_context="voice conversation", # Source label that guides fact extraction
|
|
177
|
+
user_name="User", # Speaker labels in the stored transcript
|
|
178
|
+
agent_name="Assistant",
|
|
179
|
+
enable_session_recall=True, # Recall known facts when the agent starts
|
|
180
|
+
session_recall_query="Who is this user? ...",
|
|
181
|
+
enable_recall=True, # Recall before each reply (pipelines)
|
|
182
|
+
enable_retain=True, # Store the conversation
|
|
183
|
+
)
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### Global configuration
|
|
187
|
+
|
|
188
|
+
```python
|
|
189
|
+
from hindsight_livekit import configure
|
|
190
|
+
|
|
191
|
+
configure(
|
|
192
|
+
hindsight_api_url="https://api.hindsight.vectorize.io",
|
|
193
|
+
api_key="hsk_...",
|
|
194
|
+
recall_budget="low",
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
memory = HindsightMemory(bank_id="user-123")
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### Using your own `Agent` or `llm_node`
|
|
201
|
+
|
|
202
|
+
`memory.attach(session)` works with any `Agent`. To add recall to an agent that already overrides `llm_node` or `on_enter`, call the memory directly instead of subclassing `HindsightAgent`:
|
|
203
|
+
|
|
204
|
+
```python
|
|
205
|
+
from livekit.agents import Agent
|
|
206
|
+
|
|
207
|
+
class MyAgent(Agent):
|
|
208
|
+
def __init__(self, memory: HindsightMemory) -> None:
|
|
209
|
+
super().__init__(instructions="...")
|
|
210
|
+
self.memory = memory
|
|
211
|
+
|
|
212
|
+
async def on_enter(self):
|
|
213
|
+
if memories := await self.memory.recall_session_context():
|
|
214
|
+
await self.update_instructions(f"{self.instructions}\n\n{memories}")
|
|
215
|
+
|
|
216
|
+
async def llm_node(self, chat_ctx, tools, model_settings):
|
|
217
|
+
chat_ctx = await self.memory.inject_memories(chat_ctx)
|
|
218
|
+
async for chunk in Agent.default.llm_node(self, chat_ctx, tools, model_settings):
|
|
219
|
+
yield chunk
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
## Prerequisites
|
|
223
|
+
|
|
224
|
+
- `livekit-agents` 1.2 or later
|
|
225
|
+
- A running Hindsight instance, self-hosted or [Hindsight Cloud](https://ui.hindsight.vectorize.io)
|
|
226
|
+
|
|
227
|
+
## Running Tests
|
|
228
|
+
|
|
229
|
+
```bash
|
|
230
|
+
uv sync
|
|
231
|
+
uv run pytest tests -v
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
The unit tests run a real LiveKit `AgentSession` with a scripted LLM and a mocked Hindsight client. The live tests in `tests/test_live_integration.py` run against a real Hindsight server when `HINDSIGHT_API_URL` is set, and also against real pipeline and realtime models through LiveKit Inference when LiveKit Cloud credentials are set:
|
|
235
|
+
|
|
236
|
+
```bash
|
|
237
|
+
docker run --rm -p 8888:8888 -e HINDSIGHT_API_LLM_PROVIDER=none ghcr.io/vectorize-io/hindsight:latest
|
|
238
|
+
HINDSIGHT_API_URL=http://localhost:8888 uv run pytest tests -v
|
|
239
|
+
|
|
240
|
+
# optional: real models via LiveKit Inference
|
|
241
|
+
LIVEKIT_URL=wss://... LIVEKIT_API_KEY=... LIVEKIT_API_SECRET=... HINDSIGHT_API_URL=http://localhost:8888 uv run pytest tests -v
|
|
242
|
+
```
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
hindsight_livekit/__init__.py,sha256=OA-AvFrvZClGocM7msFcnPdKAVAJVmzsj4nQEb1kxLc,1432
|
|
2
|
+
hindsight_livekit/_client.py,sha256=qeFVo0ql5zjOaGWDoNITaI66-7oLSJef6ih1QPpBm5s,1465
|
|
3
|
+
hindsight_livekit/agent.py,sha256=J1Efha5Z2PkGEJiIUsinh4EfsoV7Y_wrAQ4o7Y3m39I,3747
|
|
4
|
+
hindsight_livekit/config.py,sha256=AEEpG_OFXveOW0WubWqPDgmS0LenxY20tbdZ013KPvc,2950
|
|
5
|
+
hindsight_livekit/errors.py,sha256=gPlcCs_KajuRSsLdT2Fsv_msUnrPbe6xE8KZetBrBq8,173
|
|
6
|
+
hindsight_livekit/memory.py,sha256=XaQUuImN_NlCFrrOtb3YvIUB5ggI8-UsTdHazAF-hxM,17585
|
|
7
|
+
hindsight_livekit/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
hindsight_livekit/tools.py,sha256=bg67Qg1GFJnYE-_9i_0DMhFoUEB2m8MU71huLrlwkYk,6543
|
|
9
|
+
hindsight_livekit-0.1.0.dist-info/METADATA,sha256=kfCSkT0wDHqmywDUkiph3Ncd_KcVQB59Jp9BNLRL_8Q,12439
|
|
10
|
+
hindsight_livekit-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
11
|
+
hindsight_livekit-0.1.0.dist-info/licenses/LICENSE,sha256=juw1vPPlfLMPts6FbNdNfpe_j3FinPgCJpbFppuHpB4,1062
|
|
12
|
+
hindsight_livekit-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Naman
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|