Skip to content

Commit 7de6dfd

Browse files
sogadaikiclaude
andcommitted
feat: add cross-session memory (summarize + inject context)
When a user starts a new session via /new, the previous session's conversation is summarized by Claude and stored in SQLite. On the next new session, stored summaries are injected into the system prompt, giving Claude context about prior work. - Migration 5: session_memories table - SessionMemoryModel + SessionMemoryRepository - SessionMemoryService (summarize + retrieve) - System prompt injection via memory_context parameter - Feature flag: ENABLE_SESSION_MEMORY (default false) - Background summarization on /new (fire-and-forget) - Unit tests for service and repository Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8b6eeba commit 7de6dfd

File tree

13 files changed

+1023
-19
lines changed

13 files changed

+1023
-19
lines changed

src/bot/orchestrator.py

Lines changed: 70 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -464,9 +464,7 @@ async def agentic_start(
464464
dir_display = f"<code>{current_dir}/</code>"
465465

466466
safe_name = escape_html(user.first_name)
467-
welcome_text = t(
468-
"welcome", self._lang(), name=safe_name, dir=dir_display
469-
)
467+
welcome_text = t("welcome", self._lang(), name=safe_name, dir=dir_display)
470468
await update.message.reply_text(
471469
f"{welcome_text}{sync_line}",
472470
parse_mode="HTML",
@@ -476,12 +474,51 @@ async def agentic_new(
476474
self, update: Update, context: ContextTypes.DEFAULT_TYPE
477475
) -> None:
478476
"""Reset session, one-line confirmation."""
477+
old_session_id = context.user_data.get("claude_session_id")
478+
479479
context.user_data["claude_session_id"] = None
480480
context.user_data["session_started"] = True
481481
context.user_data["force_new_session"] = True
482482

483483
await update.message.reply_text(t("session_reset", self._lang()))
484484

485+
# Trigger background summarization of the old session
486+
if old_session_id and self.settings.enable_session_memory:
487+
memory_service = context.bot_data.get("session_memory_service")
488+
if memory_service:
489+
current_dir = context.user_data.get(
490+
"current_directory", str(self.settings.approved_directory)
491+
)
492+
asyncio.create_task(
493+
self._summarize_session_safe(
494+
memory_service,
495+
old_session_id,
496+
update.effective_user.id,
497+
str(current_dir),
498+
)
499+
)
500+
501+
async def _summarize_session_safe(
502+
self,
503+
memory_service: Any,
504+
session_id: str,
505+
user_id: int,
506+
project_path: str,
507+
) -> None:
508+
"""Summarize session in background, logging errors instead of raising."""
509+
try:
510+
await memory_service.summarize_session(
511+
session_id=session_id,
512+
user_id=user_id,
513+
project_path=project_path,
514+
)
515+
except Exception as e:
516+
logger.warning(
517+
"Background session summarization failed",
518+
session_id=session_id,
519+
error=str(e),
520+
)
521+
485522
async def agentic_status(
486523
self, update: Update, context: ContextTypes.DEFAULT_TYPE
487524
) -> None:
@@ -507,7 +544,13 @@ async def agentic_status(
507544
pass
508545

509546
await update.message.reply_text(
510-
t("status", self._lang(), dir=dir_display, session=session_status, cost=cost_str)
547+
t(
548+
"status",
549+
self._lang(),
550+
dir=dir_display,
551+
session=session_status,
552+
cost=cost_str,
553+
)
511554
)
512555

513556
def _get_verbose_level(self, context: ContextTypes.DEFAULT_TYPE) -> int:
@@ -526,7 +569,12 @@ async def agentic_verbose(
526569
if not args:
527570
current = self._get_verbose_level(context)
528571
await update.message.reply_text(
529-
t("verbose_current", lang, level=current, label=verbose_label(current, lang)),
572+
t(
573+
"verbose_current",
574+
lang,
575+
level=current,
576+
label=verbose_label(current, lang),
577+
),
530578
parse_mode="HTML",
531579
)
532580
return
@@ -671,7 +719,9 @@ async def _on_stream(update_obj: StreamUpdate) -> None:
671719
# Collapse to first meaningful line, cap length
672720
first_line = text.split("\n", 1)[0].strip()
673721
if first_line:
674-
tool_log.append({"kind": "text", "detail": first_line[:120]})
722+
tool_log.append(
723+
{"kind": "text", "detail": first_line[:120]}
724+
)
675725

676726
# Throttle progress message edits to avoid Telegram rate limits
677727
now = time.time()
@@ -870,7 +920,11 @@ async def agentic_document(
870920
max_size = 10 * 1024 * 1024
871921
if document.file_size > max_size:
872922
await update.message.reply_text(
873-
t("file_too_large", lang, size=f"{document.file_size / 1024 / 1024:.1f}")
923+
t(
924+
"file_too_large",
925+
lang,
926+
size=f"{document.file_size / 1024 / 1024:.1f}",
927+
)
874928
)
875929
return
876930

@@ -1111,7 +1165,12 @@ async def agentic_repo(
11111165
session_badge = " · session resumed" if session_id else ""
11121166

11131167
await update.message.reply_text(
1114-
t("repo_switched", lang, name=escape_html(target_name), badges=f"{git_badge}{session_badge}"),
1168+
t(
1169+
"repo_switched",
1170+
lang,
1171+
name=escape_html(target_name),
1172+
badges=f"{git_badge}{session_badge}",
1173+
),
11151174
parse_mode="HTML",
11161175
)
11171176
return
@@ -1127,7 +1186,9 @@ async def agentic_repo(
11271186
key=lambda d: d.name,
11281187
)
11291188
except OSError as e:
1130-
await update.message.reply_text(t("repo_workspace_error", lang, error=str(e)))
1189+
await update.message.reply_text(
1190+
t("repo_workspace_error", lang, error=str(e))
1191+
)
11311192
return
11321193

11331194
if not entries:

src/claude/facade.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import structlog
1010

1111
from ..config.settings import Settings
12+
from .memory import SessionMemoryService
1213
from .sdk_integration import ClaudeResponse, ClaudeSDKManager, StreamUpdate
1314
from .session import SessionManager
1415

@@ -23,11 +24,13 @@ def __init__(
2324
config: Settings,
2425
sdk_manager: Optional[ClaudeSDKManager] = None,
2526
session_manager: Optional[SessionManager] = None,
27+
memory_service: Optional[SessionMemoryService] = None,
2628
):
2729
"""Initialize Claude integration facade."""
2830
self.config = config
2931
self.sdk_manager = sdk_manager or ClaudeSDKManager(config)
3032
self.session_manager = session_manager
33+
self.memory_service = memory_service
3134

3235
async def run_command(
3336
self,
@@ -78,13 +81,22 @@ async def run_command(
7881
# For new sessions, don't pass session_id to Claude Code
7982
claude_session_id = session.session_id if should_continue else None
8083

84+
# Inject memory context for new sessions
85+
memory_context = None
86+
if is_new and self.memory_service and self.config.enable_session_memory:
87+
memory_context = await self.memory_service.get_memory_context(
88+
user_id=user_id,
89+
project_path=str(working_directory),
90+
)
91+
8192
try:
8293
response = await self._execute(
8394
prompt=prompt,
8495
working_directory=working_directory,
8596
session_id=claude_session_id,
8697
continue_session=should_continue,
8798
stream_callback=on_stream,
99+
memory_context=memory_context,
88100
)
89101
except Exception as resume_error:
90102
# If resume failed (e.g., session expired/missing on Claude's side),
@@ -109,6 +121,7 @@ async def run_command(
109121
session_id=None,
110122
continue_session=False,
111123
stream_callback=on_stream,
124+
memory_context=memory_context,
112125
)
113126
else:
114127
raise
@@ -152,6 +165,7 @@ async def _execute(
152165
session_id: Optional[str] = None,
153166
continue_session: bool = False,
154167
stream_callback: Optional[Callable] = None,
168+
memory_context: Optional[str] = None,
155169
) -> ClaudeResponse:
156170
"""Execute command via SDK."""
157171
return await self.sdk_manager.execute_command(
@@ -160,6 +174,7 @@ async def _execute(
160174
session_id=session_id,
161175
continue_session=continue_session,
162176
stream_callback=stream_callback,
177+
memory_context=memory_context,
163178
)
164179

165180
async def _find_resumable_session(

src/claude/memory.py

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
"""Session memory service for cross-session context.
2+
3+
Summarizes ended sessions and injects context into new sessions.
4+
"""
5+
6+
from typing import List, Optional
7+
8+
import structlog
9+
10+
from ..config.settings import Settings
11+
from ..storage.facade import Storage
12+
from ..storage.models import MessageModel
13+
from .sdk_integration import ClaudeSDKManager
14+
15+
logger = structlog.get_logger()
16+
17+
_SUMMARIZATION_PROMPT = (
18+
"Summarize the following conversation between a user and an AI coding assistant. "
19+
"Focus on: (1) what the user was working on, (2) key decisions made, "
20+
"(3) problems encountered and how they were resolved, (4) current state of the work. "
21+
"Keep the summary concise (3-5 bullet points, max 500 words).\n\n"
22+
"Conversation:\n{transcript}"
23+
)
24+
25+
_MAX_TRANSCRIPT_CHARS = 12000
26+
27+
28+
class SessionMemoryService:
29+
"""Manages session memory: summarization and retrieval."""
30+
31+
def __init__(
32+
self,
33+
storage: Storage,
34+
sdk_manager: ClaudeSDKManager,
35+
config: Settings,
36+
):
37+
self.storage = storage
38+
self.sdk_manager = sdk_manager
39+
self.config = config
40+
41+
async def summarize_session(
42+
self,
43+
session_id: str,
44+
user_id: int,
45+
project_path: str,
46+
) -> Optional[str]:
47+
"""Summarize a session and store the memory."""
48+
messages = await self.storage.messages.get_session_messages(
49+
session_id, limit=50
50+
)
51+
52+
if len(messages) < self.config.session_memory_min_messages:
53+
logger.info(
54+
"Session too short to summarize",
55+
session_id=session_id,
56+
message_count=len(messages),
57+
)
58+
return None
59+
60+
transcript = self._build_transcript(messages)
61+
summary = await self._generate_summary(transcript)
62+
63+
await self.storage.session_memories.save_memory(
64+
user_id=user_id,
65+
project_path=project_path,
66+
session_id=session_id,
67+
summary=summary,
68+
)
69+
70+
await self.storage.session_memories.deactivate_old_memories(
71+
user_id=user_id,
72+
project_path=project_path,
73+
keep_count=self.config.session_memory_max_count,
74+
)
75+
76+
logger.info(
77+
"Session memory saved",
78+
session_id=session_id,
79+
summary_length=len(summary),
80+
)
81+
return summary
82+
83+
async def get_memory_context(
84+
self,
85+
user_id: int,
86+
project_path: str,
87+
) -> Optional[str]:
88+
"""Retrieve stored memories formatted for system prompt injection."""
89+
memories = await self.storage.session_memories.get_active_memories(
90+
user_id=user_id,
91+
project_path=project_path,
92+
limit=self.config.session_memory_max_count,
93+
)
94+
95+
if not memories:
96+
return None
97+
98+
header = (
99+
"## Previous Session Context\n"
100+
"Summaries from previous sessions with this user:\n"
101+
)
102+
sections = []
103+
for mem in memories:
104+
ts = mem.created_at.isoformat() if mem.created_at else "unknown"
105+
sections.append(f"- [{ts}] {mem.summary}")
106+
107+
context = header + "\n".join(sections)
108+
109+
# Cap total length to avoid bloating system prompt
110+
if len(context) > 2000:
111+
context = context[:2000] + "\n... (truncated)"
112+
113+
return context
114+
115+
def _build_transcript(self, messages: List[MessageModel]) -> str:
116+
"""Build a condensed transcript from messages."""
117+
# Messages come newest-first from DB, reverse for chronological order
118+
messages = list(reversed(messages))
119+
parts = []
120+
total_len = 0
121+
122+
for msg in messages:
123+
line = f"User: {msg.prompt}"
124+
if msg.response:
125+
# Truncate long responses
126+
resp = (
127+
msg.response[:500] + "..."
128+
if len(msg.response) > 500
129+
else msg.response
130+
)
131+
line += f"\nAssistant: {resp}"
132+
133+
if total_len + len(line) > _MAX_TRANSCRIPT_CHARS:
134+
break
135+
parts.append(line)
136+
total_len += len(line)
137+
138+
return "\n\n".join(parts)
139+
140+
async def _generate_summary(self, transcript: str) -> str:
141+
"""Call Claude to generate a summary of the conversation."""
142+
from pathlib import Path
143+
144+
prompt = _SUMMARIZATION_PROMPT.format(transcript=transcript)
145+
146+
response = await self.sdk_manager.execute_command(
147+
prompt=prompt,
148+
working_directory=Path(self.config.approved_directory),
149+
session_id=None,
150+
continue_session=False,
151+
)
152+
return response.content

src/claude/sdk_integration.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,7 @@ async def execute_command(
251251
session_id: Optional[str] = None,
252252
continue_session: bool = False,
253253
stream_callback: Optional[Callable[[StreamUpdate], None]] = None,
254+
memory_context: Optional[str] = None,
254255
) -> ClaudeResponse:
255256
"""Execute Claude Code command via SDK."""
256257
start_time = asyncio.get_event_loop().time()
@@ -270,15 +271,18 @@ def _stderr_callback(line: str) -> None:
270271
stderr_lines.append(line)
271272
logger.debug("Claude CLI stderr", line=line)
272273

273-
# Build system prompt: persona (if loaded) + directory constraint
274+
# Build system prompt: persona + memory context + directory constraint
274275
dir_constraint = (
275276
f"All file operations must stay within {working_directory}. "
276277
"Use relative paths."
277278
)
279+
parts = []
278280
if self._persona_prompt:
279-
system_prompt = f"{self._persona_prompt}\n\n---\n\n{dir_constraint}"
280-
else:
281-
system_prompt = dir_constraint
281+
parts.append(self._persona_prompt)
282+
if memory_context:
283+
parts.append(memory_context)
284+
parts.append(dir_constraint)
285+
system_prompt = "\n\n---\n\n".join(parts)
282286

283287
# Build Claude Agent options
284288
cli_path = find_claude_cli(self.config.claude_cli_path)

0 commit comments

Comments
 (0)