Coverage for src/lilbee/app/memory.py: 100%

51 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-14 11:46 +0000

1"""Use-case orchestration for long-term chat memory, shared by every surface. 

2 

3Surfaces (TUI, CLI, MCP, REST, Python API) call these functions rather than 

4constructing ``MemoryRow`` objects or building owner predicates themselves, so 

5embedding, id/timestamp assignment, and scoping live in one place. 

6""" 

7 

8from __future__ import annotations 

9 

10import uuid 

11from collections.abc import Callable 

12from dataclasses import dataclass 

13from datetime import UTC, datetime 

14 

15from lilbee.app.services import get_services 

16from lilbee.core.config import cfg 

17from lilbee.core.llm_json import json_reply_format 

18from lilbee.core.vectors import Vector 

19from lilbee.data.store import ( 

20 LOCAL_OWNER, 

21 MemoryKind, 

22 MemoryRow, 

23 MemorySource, 

24 agent_recall_predicate, 

25 escape_sql_string, 

26 human_recall_predicate, 

27 local_owner_predicate, 

28) 

29 

30 

31def make_memory_row( 

32 text: str, 

33 embed: Callable[[str], Vector], 

34 *, 

35 owner: str = LOCAL_OWNER, 

36 kind: MemoryKind = MemoryKind.FACT, 

37 source: MemorySource = MemorySource.MANUAL, 

38 shared: bool = False, 

39) -> MemoryRow: 

40 """Build a fully populated ``MemoryRow`` with a fresh id, timestamps, and 

41 an embedded vector. The single id/timestamp/embedding assignment point, so 

42 callers supply only their own ``embed`` and store the result. 

43 """ 

44 now = datetime.now(UTC).isoformat() 

45 return MemoryRow( 

46 id=uuid.uuid4().hex, 

47 owner=owner, 

48 shared=shared, 

49 kind=kind, 

50 source=source, 

51 text=text, 

52 # MemoryRow serializes to JSON on write, which has no ndarray encoding. 

53 vector=embed(text).tolist(), 

54 created_at=now, 

55 updated_at=now, 

56 ) 

57 

58 

59MEMORY_DISABLED_HINT = ( 

60 "Memory is off. Turn it on with /set memory_enabled true in the TUI, " 

61 "settings_set via MCP, or memory_enabled = true in config.toml." 

62) 

63 

64 

65def memory_enabled() -> bool: 

66 """True when the memory subsystem is switched on (off by default).""" 

67 return cfg.memory_enabled 

68 

69 

70def remember( 

71 text: str, 

72 *, 

73 owner: str = LOCAL_OWNER, 

74 kind: MemoryKind = MemoryKind.FACT, 

75 source: MemorySource = MemorySource.MANUAL, 

76 shared: bool = False, 

77) -> str: 

78 """Embed *text* and store it as a memory; returns the stored id.""" 

79 services = get_services() 

80 record = make_memory_row( 

81 text, 

82 services.embedder.embed, 

83 owner=owner, 

84 kind=kind, 

85 source=source, 

86 shared=shared, 

87 ) 

88 return services.store.add_memory(record) 

89 

90 

91def recall(query: str, owner: str = LOCAL_OWNER, *, top_k: int | None = None) -> list[MemoryRow]: 

92 """Recall facts for *owner*. 

93 

94 For the human this includes memories an agent shared (so shared agent 

95 knowledge informs answers); for an agent it includes the human's shared 

96 facts. The management list (:func:`list_memories`) stays narrower. 

97 """ 

98 services = get_services() 

99 predicate = human_recall_predicate() if owner == LOCAL_OWNER else agent_recall_predicate(owner) 

100 return services.store.search_memories( 

101 services.embedder.embed_query(query), 

102 owner_predicate=predicate, 

103 top_k=cfg.memory_top_k if top_k is None else top_k, 

104 max_distance=cfg.memory_max_distance, 

105 ) 

106 

107 

108def list_memories(owner: str = LOCAL_OWNER) -> list[MemoryRow]: 

109 """List the memories *owner* owns and can manage (any kind), newest first. 

110 

111 Strictly owner-scoped (unlike :func:`recall`): the management surface shows 

112 only rows the caller can delete or re-flag, so it never lists agent-shared 

113 memories the human cannot act on. 

114 """ 

115 predicate = ( 

116 local_owner_predicate() if owner == LOCAL_OWNER else f"owner = '{escape_sql_string(owner)}'" 

117 ) 

118 return get_services().store.get_memories(owner_predicate=predicate) 

119 

120 

121def forget(memory_id: str, *, owner: str = LOCAL_OWNER) -> bool: 

122 """Delete *owner*'s memory by id; returns True when it existed and was owned. 

123 

124 Defaults to the local human's namespace (TUI/CLI/REST/Python API); MCP passes 

125 the calling agent's owner so an agent can only delete its own memories. 

126 """ 

127 return get_services().store.delete_memory(memory_id, owner=owner) 

128 

129 

130def set_memory_shared(memory_id: str, *, shared: bool, owner: str = LOCAL_OWNER) -> bool: 

131 """Set *owner*'s memory shared-with-agents flag; returns True when found and owned.""" 

132 return get_services().store.update_memory(memory_id, shared=shared, owner=owner) 

133 

134 

135def auto_extract_enabled() -> bool: 

136 """True when auto-extraction is on (requires the master gate too).""" 

137 return cfg.memory_enabled and cfg.memory_auto_extract 

138 

139 

140@dataclass(frozen=True, slots=True) 

141class SavedMemory: 

142 """A memory created by auto-extraction: its stored id, kind, and text.""" 

143 

144 id: str 

145 kind: MemoryKind 

146 text: str 

147 

148 

149def auto_extract(question: str, answer: str) -> list[SavedMemory]: 

150 """Extract durable memories from a chat turn and store them. 

151 

152 Returns one :class:`SavedMemory` per stored memory. Stored memories are 

153 ``source=EXTRACTED`` and are recalled like any other; the user manages them 

154 in ``/memories``. A no-op (returns ``[]``) unless both the master gate and 

155 ``memory_auto_extract`` are on. 

156 """ 

157 from lilbee.retrieval.query.memory_extract import extract_memories 

158 

159 if not auto_extract_enabled(): 

160 return [] 

161 services = get_services() 

162 

163 def _chat_text(messages: list[dict[str, str]], **_kwargs: object) -> str: 

164 return services.provider.chat( 

165 messages, stream=False, options={"response_format": json_reply_format()} 

166 ).text 

167 

168 extracted = extract_memories(question, answer, _chat_text) 

169 stored: list[SavedMemory] = [] 

170 for memory in extracted: 

171 memory_id = remember(memory.text, kind=memory.kind, source=MemorySource.EXTRACTED) 

172 stored.append(SavedMemory(id=memory_id, kind=memory.kind, text=memory.text)) 

173 return stored