Files
OpenOPC/opc/layer5_memory/memory_manager.py
T
2026-07-01 17:56:31 +08:00

1795 lines
72 KiB
Python

"""Memory hierarchy manager for global/project/session/employee-project scopes."""
from __future__ import annotations
import json
import re
from datetime import datetime
from pathlib import Path
from typing import Any
from loguru import logger
from opc.core.models import (
AgentMemorySnapshotRecord,
Phase,
SessionLinkRecord,
SessionMemorySnapshotRecord,
SessionMessageRecord,
SessionPartRecord,
SessionRecord,
TaskStatus,
)
from opc.layer5_memory.employee_evolution import EmployeeEvolutionManager
from opc.layer5_memory.markdown_memory import MarkdownMemoryStore
from opc.layer2_organization.work_item_identity import projection_id_for_task, work_item_identity_payload_for_task
from opc.layer4_tools.output_budget import clip_text
class MemoryManager:
"""Manages four memory scopes.
- Global memory: durable cross-project memory in `memory/global.md`
- Project memory: durable project memory in `memory/projects/<project_id>.md`
- Session memory: persisted transcript + session memory snapshots
- Employee-project memory: process/final employee memory in the store
"""
def __init__(
self,
opc_home: Path,
project_id: str | None = None,
store: Any | None = None,
) -> None:
self.opc_home = opc_home
self.project_id = project_id
self.store = store
self.markdown_store = MarkdownMemoryStore(opc_home)
self.employee_evolution = EmployeeEvolutionManager(opc_home)
self.history_compactor: Any | None = None
self.global_memory_dir = opc_home / "memory"
self.global_memory_dir.mkdir(parents=True, exist_ok=True)
self.project_memory_dir: Path | None = None
if project_id:
self.project_memory_dir = self.global_memory_dir / "projects"
self.project_memory_dir.mkdir(parents=True, exist_ok=True)
def set_project(self, project_id: str | None) -> None:
self.project_id = project_id
self.project_memory_dir = None
if project_id:
self.project_memory_dir = self.global_memory_dir / "projects"
self.project_memory_dir.mkdir(parents=True, exist_ok=True)
def set_history_compactor(self, compactor: Any | None) -> None:
self.history_compactor = compactor
def _resolve_project_id(self, project_id: str | None = None) -> str:
return str(project_id or self.project_id or "default")
def _project_root(self, project_id: str | None) -> Path:
return self.opc_home / "projects" / self._resolve_project_id(project_id)
def _project_memory_path_for_id(self, project_id: str | None) -> Path:
return self.markdown_store.memory_path(self._resolve_project_id(project_id))
def _project_history_path_for_id(self, project_id: str | None) -> Path:
return self._project_root(project_id) / "HISTORY.md"
# --- Durable Markdown memory ---
def _memory_path(self, project: bool = False) -> Path:
project_id = self._resolve_project_id() if project and self.project_id else None
return self.markdown_store.memory_path(project_id)
def load_memory(self, project: bool = False) -> str:
project_id = self._resolve_project_id() if project and self.project_id else None
return self.markdown_store.load_visible_text(project_id)
def save_memory(self, content: str, project: bool = False) -> None:
project_id = self._resolve_project_id() if project and self.project_id else None
self.markdown_store.save_visible_text(content, project_id)
logger.debug(f"Memory saved: {self.markdown_store.memory_path(project_id)}")
def append_memory(self, entry: str, project: bool = False) -> None:
project_id = self._resolve_project_id() if project and self.project_id else None
self.markdown_store.append_visible_entry(entry, project_id)
def delete_project(self, project_id: str) -> None:
self.markdown_store.delete_project(project_id)
# --- Legacy HISTORY compatibility ---
def _history_path(self, project: bool = False) -> Path:
if project and self.project_memory_dir:
return self.project_memory_dir / "HISTORY.md"
return self.global_memory_dir / "HISTORY.md"
def load_history(self, project: bool = False) -> str:
_ = project
return ""
def append_history(self, task_summary: dict[str, Any], project: bool = False) -> None:
_ = (task_summary, project)
def append_autonomy_event(self, event: dict[str, Any], project: bool = False) -> None:
_ = (event, project)
def record_task_completion(self, task: Any, result_content: str, project: bool = False) -> None:
_ = (task, result_content, project)
async def record_task_completion_async(
self,
task: Any,
result_content: str,
project: bool = False,
*,
record_evolution: bool = True,
record_reflections: bool = True,
) -> None:
self.record_task_completion(task=task, result_content=result_content, project=project)
is_company_mode = getattr(task, "metadata", {}).get("execution_mode") == "company_mode"
legacy_company_evolution_enabled = bool(getattr(task, "metadata", {}).get("enable_legacy_employee_evolution", False))
if (
record_evolution
and is_company_mode
and legacy_company_evolution_enabled
and getattr(task, "metadata", {}).get("employee_assignment")
):
self.employee_evolution.record_work_item_completion(task=task, result_content=result_content)
if not self.store:
return
project_id = getattr(task, "project_id", None) or (self.project_id or "default")
role_id = getattr(task, "assigned_to", "") or getattr(task, "metadata", {}).get("work_item_role_id", "")
projection_id = projection_id_for_task(task)
decisions = list(getattr(task, "metadata", {}).get("decisions", []))
risks = list(getattr(task, "metadata", {}).get("risks", []))
artifacts = list(getattr(task, "metadata", {}).get("artifacts", []))
open_questions = list(getattr(task, "metadata", {}).get("open_questions", []))
if role_id:
from opc.core.models import RoleMemoryRecord
await self.store.record_role_memory(
RoleMemoryRecord(
project_id=project_id,
role_id=role_id,
summary=f"{getattr(task, 'title', 'Task')}: {(result_content or '').strip()}",
details={
"task_id": getattr(task, "id", None),
"projection_id": projection_id,
},
)
)
if decisions or risks or open_questions:
from opc.core.models import WorkItemDecisionRecord
await self.store.record_work_item_decision(
WorkItemDecisionRecord(
project_id=project_id,
task_id=getattr(task, "id", None),
role_id=role_id,
projection_id=projection_id,
category="work_item_completion",
summary=(getattr(task, "metadata", {}).get("work_item_summary_for_downstream") or result_content or getattr(task, "title", "Task")),
details={
"decisions": decisions,
"risks": risks,
"open_questions": open_questions,
},
)
)
if artifacts:
from opc.core.models import ArtifactRecord
for item in artifacts:
text = str(item)
location = text.split(": ", 1)[1] if ": " in text else text
artifact_type = text.split(":", 1)[0] if ":" in text else "generic"
await self.store.record_artifact(
ArtifactRecord(
project_id=project_id,
task_id=getattr(task, "id", None),
projection_id=projection_id,
role_id=role_id,
name=text,
artifact_type=artifact_type,
location=location,
details={"source": "task_metadata"},
)
)
if record_reflections and is_company_mode and legacy_company_evolution_enabled:
await self._record_project_reflections_if_ready(task)
async def record_company_feedback_outcomes(
self,
*,
delivery_task: Any,
work_item_tasks: list[Any],
feedback: dict[str, Any],
evaluation: dict[str, Any],
) -> None:
employee_outcomes = {
str(item.get("employee_id", "")).strip(): dict(item)
for item in list(evaluation.get("employees", []))
if str(item.get("employee_id", "")).strip()
}
overall_outcome = str(evaluation.get("overall_outcome", "") or "partial_success").strip() or "partial_success"
feedback_summary = str(evaluation.get("summary", "") or feedback.get("raw_feedback", "")).strip()
for task in work_item_tasks:
result_content = str(getattr(task, "result", {}).get("content", "") or "").strip()
await self.record_task_completion_async(
task=task,
result_content=result_content,
project=bool(getattr(task, "project_id", None) and getattr(task, "project_id", None) != "default"),
record_evolution=False,
record_reflections=False,
)
assignment = dict(getattr(task, "metadata", {}).get("employee_assignment", {}) or {})
employee_id = str(assignment.get("employee_id", "")).strip()
if not employee_id:
continue
employee_feedback = employee_outcomes.get(employee_id, {})
self.employee_evolution.record_work_item_completion(
task=task,
result_content=result_content,
outcome=str(employee_feedback.get("outcome", overall_outcome) or overall_outcome),
feedback_summary=feedback_summary,
strengths=list(employee_feedback.get("strengths", [])),
weaknesses=list(employee_feedback.get("weaknesses", [])),
rationale=str(employee_feedback.get("reason", "")).strip(),
)
partial = overall_outcome != "success"
await self._record_project_reflections_and_finalize(
delivery_task,
work_item_tasks,
partial=partial,
feedback=feedback,
evaluation=evaluation,
)
async def _record_project_reflections_if_ready(self, task: Any) -> None:
execution_task_ids = [
str(item).strip()
for item in list(getattr(task, "metadata", {}).get("execution_task_ids", []))
if str(item).strip()
]
if not execution_task_ids or not self.store:
return
project_id = getattr(task, "project_id", None) or (self.project_id or "default")
tasks = await self.store.get_tasks(project_id=project_id)
task_by_id = {item.id: item for item in tasks}
task_by_id[getattr(task, "id", "")] = task
work_item_tasks = [task_by_id[item_id] for item_id in execution_task_ids if item_id in task_by_id]
if len(work_item_tasks) != len(execution_task_ids):
return
terminal = {TaskStatus.DONE, TaskStatus.FAILED, TaskStatus.CANCELLED}
if any(item.status not in terminal for item in work_item_tasks):
return
has_failures = any(item.status != TaskStatus.DONE for item in work_item_tasks)
await self._record_project_reflections_and_finalize(
task,
work_item_tasks,
partial=has_failures,
)
async def _record_project_reflections_and_finalize(
self,
delivery_task: Any,
work_item_tasks: list[Any],
*,
partial: bool = False,
feedback: dict[str, Any] | None = None,
evaluation: dict[str, Any] | None = None,
) -> None:
reflection_results = self.employee_evolution.record_project_reflections(
delivery_task,
work_item_tasks,
partial=partial,
feedback=feedback,
evaluation=evaluation,
)
await self._finalize_employee_project_memories(
delivery_task=delivery_task,
work_item_tasks=work_item_tasks,
reflection_results=reflection_results,
feedback=feedback,
evaluation=evaluation,
)
async def _finalize_employee_project_memories(
self,
*,
delivery_task: Any,
work_item_tasks: list[Any],
reflection_results: list[dict[str, Any]],
feedback: dict[str, Any] | None = None,
evaluation: dict[str, Any] | None = None,
) -> None:
if not self.store:
return
project_id = getattr(delivery_task, "project_id", None) or self._resolve_project_id()
reflections_by_employee = {
str(item.get("employee_id", "")).strip(): dict(item.get("reflection", {}) or {})
for item in reflection_results
if str(item.get("employee_id", "")).strip()
}
groups: dict[str, dict[str, Any]] = {}
for task in work_item_tasks:
assignment = dict(getattr(task, "metadata", {}).get("employee_assignment", {}) or {})
employee_id = str(assignment.get("employee_id", "")).strip()
session_id = str(getattr(task, "session_id", "") or "").strip()
if not employee_id:
continue
key = employee_id
entry = groups.setdefault(
key,
{
"employee_id": employee_id,
"role_id": str(assignment.get("role_id") or getattr(task, "assigned_to", "") or "").strip(),
"session_ids": [],
"tasks": [],
},
)
if not entry["role_id"]:
entry["role_id"] = str(assignment.get("role_id") or getattr(task, "assigned_to", "") or "").strip()
if session_id and session_id not in entry["session_ids"]:
entry["session_ids"].append(session_id)
entry["tasks"].append(task)
for group in groups.values():
employee_id = group["employee_id"]
role_id = group["role_id"]
reflection = reflections_by_employee.get(employee_id, {})
existing_final = await self.store.get_agent_memory_snapshot(
project_id=project_id,
employee_id=employee_id,
memory_kind="final",
memory_scope="project",
)
if existing_final:
for session_id in group["session_ids"]:
await self.store.delete_agent_memory_snapshots(
project_id=project_id,
session_id=session_id,
employee_id=employee_id,
memory_kind="process",
memory_scope="session",
)
continue
process_memory, process_snapshot = await self._build_project_process_memory(
project_id=project_id,
employee_id=employee_id,
session_ids=list(group["session_ids"]),
)
if self.history_compactor:
final_result = await self.history_compactor.finalize_agent_memory(
project_id=project_id,
session_id="",
employee_id=employee_id,
role_id=role_id,
process_memory=process_memory,
reflection_payload={
**reflection,
"feedback": feedback or {},
"evaluation": evaluation or {},
},
)
else:
final_result = self._fallback_final_agent_memory(
process_memory=process_memory,
reflection_payload=reflection,
)
memory_text = str(final_result.get("memory_text", "")).strip()
if not memory_text:
continue
summary_text = str(final_result.get("summary_text", "")).strip() or memory_text
metadata = dict(final_result.get("metadata", {}) or {})
metadata.update({
"reflection": reflection,
"task_ids": [str(getattr(task, "id", "")) for task in group["tasks"]],
"session_ids": list(group["session_ids"]),
})
await self.store.save_agent_memory_snapshot(
AgentMemorySnapshotRecord(
project_id=project_id,
session_id="",
employee_id=employee_id,
role_id=role_id,
memory_scope="project",
memory_kind="final",
summary_message_id=process_snapshot.summary_message_id if process_snapshot else "",
source_boundary_message_id=process_snapshot.source_boundary_message_id if process_snapshot else "",
summary_text=summary_text,
memory_text=memory_text,
metadata=metadata,
)
)
for session_id in group["session_ids"]:
await self.store.delete_agent_memory_snapshots(
project_id=project_id,
session_id=session_id,
employee_id=employee_id,
memory_kind="process",
memory_scope="session",
)
async def _build_project_process_memory(
self,
*,
project_id: str,
employee_id: str,
session_ids: list[str],
) -> tuple[str, AgentMemorySnapshotRecord | None]:
sections: list[str] = []
latest_snapshot: AgentMemorySnapshotRecord | None = None
for session_id in session_ids:
snapshot = await self.store.get_agent_memory_snapshot(
project_id=project_id,
session_id=session_id,
employee_id=employee_id,
memory_kind="process",
memory_scope="session",
)
if snapshot and snapshot.memory_text.strip():
if latest_snapshot is None:
latest_snapshot = snapshot
sections.append(f"## Session {session_id}\n{snapshot.memory_text.strip()}")
continue
history_tail = await self.build_employee_history_tail_messages(
project_id=project_id,
session_id=session_id,
employee_id=employee_id,
)
draft = self._history_messages_to_process_memory(history_tail)
if draft:
sections.append(f"## Session {session_id}\n{draft}")
return "\n\n".join(section for section in sections if section).strip(), latest_snapshot
def _history_messages_to_process_memory(self, messages: list[dict[str, Any]]) -> str:
if not messages:
return ""
lines = ["## Process Memory Draft"]
for item in messages:
role = str(item.get("role", "")).strip()
content = str(item.get("content", "")).strip()
if not content:
continue
prefix = f"[{role}] " if role else ""
lines.append(f"- {prefix}{content}")
return "\n".join(lines).strip()
def _fallback_final_agent_memory(
self,
*,
process_memory: str,
reflection_payload: dict[str, Any],
) -> dict[str, Any]:
what_worked = [str(item).strip() for item in list(reflection_payload.get("what_worked", [])) if str(item).strip()]
watchouts = [str(item).strip() for item in list(reflection_payload.get("mistakes_to_avoid", [])) if str(item).strip()]
preferred_tools = [str(item).strip() for item in list(reflection_payload.get("tool_preferences", [])) if str(item).strip()]
reviewer_preferences = [str(item).strip() for item in list(reflection_payload.get("reviewer_preferences", [])) if str(item).strip()]
checklist = [str(item).strip() for item in list(reflection_payload.get("reusable_checklist", [])) if str(item).strip()]
parts = [
"## Effective Patterns",
*([f"- {item}" for item in what_worked] or ["- (none)"]),
"",
"## Watchouts",
*([f"- {item}" for item in watchouts] or ["- (none)"]),
"",
"## Preferred Tools",
*([f"- {item}" for item in preferred_tools] or ["- (none)"]),
"",
"## Reviewer Preferences",
*([f"- {item}" for item in reviewer_preferences] or ["- (none)"]),
"",
"## Reusable Checklist",
*([f"- {item}" for item in checklist] or ["- (none)"]),
]
summary = str(reflection_payload.get("project_summary", "")).strip() or process_memory.strip()
return {
"summary_text": summary,
"memory_text": "\n".join(parts).strip(),
"metadata": {
"effective_patterns": what_worked,
"watchouts": watchouts,
"preferred_tools": preferred_tools,
"reviewer_preferences": reviewer_preferences,
"reusable_checklist": checklist,
},
}
# --- Session memory and context building ---
def _build_message_metadata(
self,
session: SessionRecord | None,
*,
metadata: dict[str, Any] | None = None,
agent_id: str | None = None,
task_id: str | None = None,
) -> dict[str, Any]:
merged: dict[str, Any] = {}
if session:
merged["project_id"] = session.project_id
merged["session_id"] = session.session_id
if session.parent_session_id:
merged["parent_session_id"] = session.parent_session_id
session_metadata = dict(session.metadata or {})
for key in (
"employee_id",
"role_id",
"work_item_projection_id",
"work_item_turn_type",
"work_item_projection_id",
"origin_session_id",
"interface",
):
value = session_metadata.get(key)
if value not in (None, "", [], {}):
merged[key] = value
if task_id:
merged["task_id"] = task_id
if agent_id and not merged.get("role_id"):
merged["role_id"] = agent_id
if metadata:
merged.update(dict(metadata))
if agent_id and not merged.get("role_id"):
merged["role_id"] = agent_id
return merged
async def ensure_session(
self,
session_id: str,
project_id: str | None = None,
*,
title: str = "",
mode: str = "primary",
parent_session_id: str | None = None,
metadata: dict[str, Any] | None = None,
) -> SessionRecord | None:
if not self.store:
return None
shared_role_session = bool((metadata or {}).get("shared_role_session", False))
existing = await self.store.get_session(session_id)
if existing:
changed = False
if title and not existing.title:
existing.title = title
changed = True
if shared_role_session and existing.parent_session_id is not None:
existing.parent_session_id = None
changed = True
if parent_session_id and not shared_role_session and existing.parent_session_id != parent_session_id:
existing.parent_session_id = parent_session_id
changed = True
if metadata:
existing.metadata = {**existing.metadata, **metadata}
changed = True
if changed:
existing.updated_at = datetime.now()
await self.store.save_session(existing)
return existing
record = SessionRecord(
session_id=session_id,
project_id=project_id or self.project_id or "default",
parent_session_id=None if shared_role_session else parent_session_id,
title=title,
mode=mode,
metadata=dict(metadata or {}),
)
await self.store.save_session(record)
if parent_session_id and not shared_role_session:
await self.store.save_session_link(
SessionLinkRecord(
project_id=record.project_id,
session_id=parent_session_id,
linked_session_id=session_id,
link_type="child_session",
metadata={"mode": mode},
)
)
return record
async def append_session_message(
self,
session_id: str,
role: str,
*,
text: str = "",
part_type: str = "text",
project_id: str | None = None,
agent_id: str | None = None,
task_id: str | None = None,
parent_message_id: str | None = None,
summary_flag: bool = False,
metadata: dict[str, Any] | None = None,
) -> SessionMessageRecord | None:
if not self.store:
return None
session = await self.ensure_session(
session_id,
project_id=project_id,
mode="child" if role == "subagent" else "primary",
)
message_metadata = self._build_message_metadata(
session,
metadata=metadata,
agent_id=agent_id,
task_id=task_id,
)
message = SessionMessageRecord(
session_id=session_id,
role=role,
task_id=task_id,
agent_id=agent_id,
parent_message_id=parent_message_id,
summary_flag=summary_flag,
metadata=message_metadata,
)
await self.store.save_session_message(message)
if text or part_type != "text":
payload = {"text": text} if part_type == "text" else {"text": text, **dict(metadata or {})}
if part_type == "tool_output":
payload = {
"tool_name": str((metadata or {}).get("tool_name", "tool")),
"output": text,
**dict(metadata or {}),
}
await self.store.save_session_part(
SessionPartRecord(
message_id=message.message_id,
session_id=session_id,
part_type=part_type,
payload=payload,
)
)
return message
async def append_session_part(
self,
session_id: str,
message_id: str,
part_type: str,
payload: dict[str, Any],
) -> None:
if not self.store:
return
await self.store.save_session_part(
SessionPartRecord(
message_id=message_id,
session_id=session_id,
part_type=part_type,
payload=payload,
)
)
async def record_user_turn(
self,
session_id: str,
content: str,
project_id: str | None = None,
*,
metadata: dict[str, Any] | None = None,
) -> SessionMessageRecord | None:
return await self.append_session_message(
session_id=session_id,
role="user",
text=content,
project_id=project_id,
metadata=metadata,
)
async def record_assistant_turn(
self,
session_id: str,
content: str,
*,
project_id: str | None = None,
agent_id: str | None = None,
task_id: str | None = None,
metadata: dict[str, Any] | None = None,
) -> SessionMessageRecord | None:
return await self.append_session_message(
session_id=session_id,
role="assistant",
text=content,
project_id=project_id,
agent_id=agent_id,
task_id=task_id,
metadata=metadata,
)
async def record_child_session_result(
self,
parent_session_id: str,
child_session_id: str,
*,
task: Any,
result_content: str,
artifacts: dict[str, Any] | None = None,
) -> None:
if not self.store:
return
summary = str(result_content or "").strip()
assignment = dict(getattr(task, "metadata", {}).get("employee_assignment", {}) or {})
msg = await self.append_session_message(
session_id=parent_session_id,
role="assistant",
text="",
project_id=getattr(task, "project_id", None) or self.project_id or "default",
agent_id=getattr(task, "assigned_to", "") or None,
task_id=getattr(task, "id", None),
metadata={
"kind": "child_result",
"child_session_id": child_session_id,
"task_title": getattr(task, "title", ""),
"employee_id": str(assignment.get("employee_id", "")).strip(),
"role_id": str(assignment.get("role_id") or getattr(task, "assigned_to", "") or "").strip(),
**work_item_identity_payload_for_task(task),
},
)
if not msg:
return
await self.append_session_part(
parent_session_id,
msg.message_id,
"subtask_result",
{
"child_session_id": child_session_id,
"task_id": getattr(task, "id", None),
"task_title": getattr(task, "title", ""),
"agent_id": getattr(task, "assigned_to", ""),
"summary": summary,
"artifacts": self._compact_artifacts(artifacts or {}),
},
)
async def update_session_title(self, session_id: str, title: str) -> None:
"""Update the title of an existing session (unconditional overwrite)."""
if not self.store:
return
session = await self.store.get_session(session_id)
if not session:
return
session.title = title
session.updated_at = datetime.now()
await self.store.save_session(session)
async def update_session_summary(self, session_id: str, summary: str) -> None:
if not self.store:
return
session = await self.store.get_session(session_id)
if not session:
return
session.summary = summary
session.updated_at = datetime.now()
await self.store.save_session(session)
async def record_runtime_heartbeat_summary(
self,
*,
session_id: str,
role_id: str,
worker_kind: str,
summary: str,
project_id: str | None = None,
metadata: dict[str, Any] | None = None,
) -> dict[str, Any]:
if not self.store:
return {}
text = str(summary or "").strip()
if not text:
return {}
session = await self.store.get_session(session_id)
if not session:
return {}
heartbeat_entries = list((session.metadata or {}).get("runtime_heartbeat_summaries", []) or [])
heartbeat_entries.append(
{
"role_id": role_id,
"worker_kind": worker_kind,
"summary": text,
"metadata": dict(metadata or {}),
"recorded_at": datetime.now().isoformat(),
}
)
heartbeat_entries = heartbeat_entries[-24:]
await self._update_session_metadata(
session_id,
{
"runtime_heartbeat_summaries": heartbeat_entries,
"runtime_heartbeat_updated_at": datetime.now().isoformat(),
},
)
rollup = await self.build_runtime_heartbeat_context(session_id)
if rollup:
await self.update_session_summary(session_id, rollup.replace("## Runtime Heartbeats\n", "").strip())
return {
"updated": True,
"entry_count": len(heartbeat_entries),
"summary_preview": text[:240],
}
async def build_global_memory_context(self) -> str:
global_mem = self.load_memory(project=False).strip()
if not global_mem:
return ""
return f"## Global Memory\n{global_mem}"
def load_project_memory_markdown(self, project_id: str | None = None) -> str:
pid = self._resolve_project_id(project_id) if (project_id or self.project_id) else None
return self.markdown_store.load_visible_text(pid)
async def build_project_memory_context(
self,
project_id: str | None = None,
*,
include_project_knowledge: bool = False,
) -> str:
pid = self._resolve_project_id(project_id) if (project_id or self.project_id) else ""
if not pid or pid == "default":
return ""
parts: list[str] = []
project_mem = self.load_project_memory_markdown(pid).strip()
if project_mem:
parts.append(f"## Project Memory ({pid})\n{project_mem}")
if include_project_knowledge:
explicit_knowledge = self.employee_evolution.preferences.render_project_knowledge_context(pid)
if explicit_knowledge:
parts.append(f"## Project Knowledge ({pid})\n{explicit_knowledge}")
return "\n\n".join(part for part in parts if part)
async def build_project_dossier(
self,
*,
project_id: str | None = None,
run_id: str | None = None,
session_id: str | None = None,
limit: int = 8,
) -> dict[str, Any]:
pid = self._resolve_project_id(project_id) if (project_id or self.project_id) else "default"
project_memory = self.load_project_memory_markdown(pid).strip() if pid and pid != "default" else ""
if not self.store:
return {
"project_id": pid,
"latest_deliverable_summary": "",
"architecture_decisions": [],
"completed_work_items": [],
"open_issues": [],
"verification_summary": "",
"artifact_index": [],
"work_item_summaries_for_downstream": [],
"last_failure_summary": "",
"project_memory_excerpt": clip_text(
project_memory,
limit=2000,
marker="project memory excerpt truncated",
).text,
}
latest_run = None
if run_id and hasattr(self.store, "get_delegation_run"):
latest_run = await self.store.get_delegation_run(run_id)
elif hasattr(self.store, "get_latest_delegation_run"):
latest_run = await self.store.get_latest_delegation_run(pid)
effective_session_id = session_id or (getattr(latest_run, "session_id", "") or None)
session_memory = await self.build_session_memory_context(effective_session_id) if effective_session_id else ""
decisions = await self.store.get_work_item_decisions(pid, limit=limit) if hasattr(self.store, "get_work_item_decisions") else []
artifacts = await self.store.get_artifacts(pid, limit=limit) if hasattr(self.store, "get_artifacts") else []
handoffs = await self.store.get_handoff_records(pid, limit=limit) if hasattr(self.store, "get_handoff_records") else []
work_items = (
await self.store.list_delegation_work_items(latest_run.run_id)
if latest_run is not None and hasattr(self.store, "list_delegation_work_items")
else []
)
completed_work_items = [
{
"work_item_id": item.work_item_id,
"title": item.title,
"role_id": item.role_id,
"seat_id": item.seat_id,
"deliverable_summary": item.deliverable_summary or item.summary,
}
for item in work_items
if item.phase == Phase.APPROVED
][:limit]
open_issues: list[str] = []
for item in work_items:
if item.phase in {Phase.FAILED, Phase.NEEDS_ATTENTION, Phase.WAITING_DEPENDENCIES, Phase.WAITING_FOR_PEER, Phase.WAITING_FOR_CHILDREN, Phase.PAUSED}:
issue = str(item.blocked_reason or item.summary or item.title or "").strip()
if issue:
open_issues.append(issue)
for decision in decisions:
for question in list((decision.details or {}).get("open_questions", []) or []):
text = str(question or "").strip()
if text:
open_issues.append(text)
deduped_open_issues = list(dict.fromkeys(open_issues))[:limit]
verification_parts = [
str(getattr(latest_run, "latest_deliverable_summary", "") or "").strip(),
session_memory.replace("## Session Memory", "").strip(),
]
verification_summary = next((part for part in verification_parts if part), "")
last_failure_summary = ""
for item in reversed(work_items):
if item.phase == Phase.FAILED:
last_failure_summary = str(item.blocked_reason or item.summary or item.title or "").strip()
if last_failure_summary:
break
return {
"project_id": pid,
"run_id": getattr(latest_run, "run_id", "") if latest_run is not None else (run_id or ""),
"latest_deliverable_summary": str(getattr(latest_run, "latest_deliverable_summary", "") or "").strip(),
"architecture_decisions": [
{
"decision_id": item.decision_id,
"role_id": item.role_id,
"projection_id": item.projection_id,
"summary": item.summary,
"created_at": item.created_at.isoformat(),
}
for item in decisions[:limit]
],
"completed_work_items": completed_work_items,
"open_issues": deduped_open_issues,
"verification_summary": verification_summary[:1500],
"artifact_index": [
{
"artifact_id": item.artifact_id,
"name": item.name,
"artifact_type": item.artifact_type,
"location": item.location,
"status": item.status,
}
for item in artifacts[:limit]
],
"work_item_summaries_for_downstream": [
{
"handoff_id": item.handoff_id,
"from_role": item.from_role,
"to_role": item.to_role,
"summary": item.summary,
"status": item.status,
}
for item in handoffs[:limit]
],
"last_failure_summary": last_failure_summary,
"project_memory_excerpt": clip_text(
project_memory,
limit=2000,
marker="project memory excerpt truncated",
).text,
"session_memory_excerpt": clip_text(
session_memory,
limit=1200,
marker="session memory excerpt truncated",
).text,
}
async def build_memory_context(
self,
project_id: str | None = None,
session_id: str | None = None,
*,
include_project_knowledge: bool = False,
) -> str:
parts: list[str] = []
global_ctx = await self.build_global_memory_context()
if global_ctx:
parts.append(global_ctx)
project_ctx = await self.build_project_memory_context(
project_id=project_id,
include_project_knowledge=include_project_knowledge,
)
if project_ctx:
parts.append(project_ctx)
if session_id:
session_ctx = await self.build_session_memory_context(session_id)
if session_ctx:
parts.append(session_ctx)
return "\n\n".join(part for part in parts if part)
async def build_focused_memory_context(
self,
*,
query: str,
project_id: str | None = None,
session_id: str | None = None,
include_project_knowledge: bool = False,
max_chars: int = 2_400,
) -> str:
pid = self._resolve_project_id(project_id) if (project_id or self.project_id) else ""
normalized_query = " ".join(str(query or "").split()).strip()
parts: list[str] = []
global_sections = self._select_relevant_markdown_sections(
self.load_memory(project=False),
query=normalized_query,
max_sections=2,
max_chars=max_chars // 2,
)
if global_sections:
parts.append(self._render_selected_sections("Focused Global Memory", global_sections))
project_markdown = self.load_project_memory_markdown(pid) if pid and pid != "default" else ""
project_sections = self._select_relevant_markdown_sections(
project_markdown,
query=normalized_query,
max_sections=3,
max_chars=max_chars,
)
if project_sections:
parts.append(self._render_selected_sections(f"Focused Project Memory ({pid})", project_sections))
if include_project_knowledge and pid and pid != "default":
explicit_knowledge = self.employee_evolution.preferences.render_project_knowledge_context(pid)
if explicit_knowledge:
trimmed = explicit_knowledge.strip()
if len(trimmed) > max_chars // 2:
trimmed = trimmed[: max_chars // 2].rstrip() + "\n[project knowledge truncated]"
parts.append(f"## Project Knowledge ({pid})\n{trimmed}")
if session_id:
session_ctx = await self.build_session_memory_context(session_id)
if session_ctx:
parts.append(session_ctx)
return "\n\n".join(part for part in parts if part)
async def build_project_knowledge_context(self, project_id: str | None = None) -> str:
parts: list[str] = []
global_ctx = await self.build_global_memory_context()
if global_ctx:
parts.append(global_ctx)
project_ctx = await self.build_project_memory_context(
project_id=project_id,
include_project_knowledge=True,
)
if project_ctx:
parts.append(project_ctx)
return "\n\n".join(parts)
async def extract_durable_memories(
self,
*,
session_id: str,
project_id: str | None,
query: str,
assistant_response: str,
llm: Any | None = None,
min_messages: int = 4,
max_input_chars: int = 12_000,
) -> dict[str, Any]:
_ = (session_id, project_id, query, assistant_response, llm, min_messages, max_input_chars)
return {}
def _slice_transcript_from_boundary(
self,
transcript: list[dict[str, Any]],
boundary_message_id: str,
) -> list[dict[str, Any]]:
if not boundary_message_id:
return transcript
for idx, item in enumerate(transcript):
if item["message"].message_id == boundary_message_id:
return transcript[idx + 1:]
return transcript
def _slice_transcript_from_message_id(
self,
transcript: list[dict[str, Any]],
message_id: str,
) -> list[dict[str, Any]]:
if not message_id:
return transcript
for idx, item in enumerate(transcript):
if item["message"].message_id == message_id:
return transcript[idx + 1:]
return transcript
async def build_session_memory_context(self, session_id: str) -> str:
if not self.store:
return ""
snapshot = await self.store.get_latest_session_memory_snapshot(session_id)
if snapshot and snapshot.memory_text.strip():
return f"## Session Memory\n{snapshot.memory_text.strip()}"
session = await self.store.get_session(session_id)
if session and session.summary.strip():
return f"## Session Memory\n{session.summary.strip()}"
return ""
async def build_runtime_heartbeat_context(self, session_id: str) -> str:
if not self.store:
return ""
session = await self.store.get_session(session_id)
if not session:
return ""
entries = list((session.metadata or {}).get("runtime_heartbeat_summaries", []) or [])
if not entries:
return ""
latest_by_role: dict[str, dict[str, Any]] = {}
for item in entries:
if not isinstance(item, dict):
continue
role_id = str(item.get("role_id", "") or "").strip()
if not role_id:
continue
latest_by_role[role_id] = item
lines = ["## Runtime Heartbeats"]
for role_id in sorted(latest_by_role):
item = latest_by_role[role_id]
worker_kind = str(item.get("worker_kind", "") or "").strip()
label = f"{role_id} ({worker_kind})" if worker_kind else role_id
lines.append(f"- {label}: {str(item.get('summary', '') or '').strip()}")
return "\n".join(lines)
async def update_runtime_session_memory(
self,
*,
session_id: str,
project_id: str | None,
llm: Any | None,
messages: list[dict[str, Any]],
update_interval_messages: int = 4,
max_input_chars: int = 6_000,
) -> dict[str, Any]:
if not self.store or llm is None:
return {}
session = await self.store.get_session(session_id)
if not session:
return {}
visible = await self._get_visible_session_transcript(session_id)
if not visible:
return {}
current_message_count = len([item for item in visible if not getattr(item["message"], "summary_flag", False)])
previous_count = int((session.metadata or {}).get("runtime_session_memory_message_count", 0) or 0)
if current_message_count - previous_count < max(1, int(update_interval_messages or 1)):
return {}
rendered = [
self._render_session_message(item["message"], item["parts"])
for item in visible
if not getattr(item["message"], "summary_flag", False)
]
transcript_text = "\n\n".join(item for item in rendered if item).strip()
if not transcript_text:
return {}
if len(transcript_text) > max_input_chars:
transcript_text = transcript_text[-max_input_chars:]
raw = await llm.simple_chat(
prompt=json.dumps(
{
"project_id": self._resolve_project_id(project_id),
"session_id": session_id,
"conversation_excerpt": transcript_text,
},
ensure_ascii=False,
),
system=(
"You maintain a rolling session memory for a coding agent.\n"
"Return strict JSON with keys `summary_text` and `memory_text`.\n"
"`memory_text` should be concise markdown with sections `## Primary Goal`, "
"`## Current State`, `## Active Constraints`, and `## Open Risks` when applicable.\n"
"Keep it durable for the next few turns; do not include verbose logs."
),
task_type="quick_tasks",
)
parsed = self._parse_json_object(raw)
memory_text = str(parsed.get("memory_text", "") or parsed.get("summary_text", "")).strip()
if not memory_text:
return {}
latest_message_id = str(visible[-1]["message"].message_id)
await self.store.save_session_memory_snapshot(
SessionMemorySnapshotRecord(
project_id=self._resolve_project_id(project_id),
session_id=session_id,
summary_message_id=latest_message_id,
source_boundary_message_id=latest_message_id,
summary_text=str(parsed.get("summary_text", "") or memory_text).strip(),
memory_text=memory_text,
metadata={"source": "runtime_background_session_memory"},
)
)
await self.update_session_summary(session_id, memory_text)
await self._update_session_metadata(
session_id,
{
"runtime_session_memory_message_count": current_message_count,
"runtime_session_memory_updated_at": datetime.now().isoformat(),
},
)
return {
"updated": True,
"message_count": current_message_count,
"summary_preview": memory_text[:240],
}
async def record_verification_feedback(
self,
*,
task: Any,
verdict: str,
content: str,
) -> dict[str, Any]:
_ = (task, verdict, content)
return {}
async def _update_session_metadata(self, session_id: str, updates: dict[str, Any]) -> None:
if not self.store:
return
session = await self.store.get_session(session_id)
if not session:
return
session.metadata = {**dict(session.metadata or {}), **dict(updates or {})}
session.updated_at = datetime.now()
await self.store.save_session(session)
@staticmethod
def _memory_keywords(text: str) -> set[str]:
stopwords = {
"the", "and", "for", "with", "that", "this", "from", "into", "your",
"about", "after", "before", "while", "when", "where", "have", "has",
"using", "use", "used", "task", "work", "project", "session", "agent",
"should", "would", "could", "then", "than", "them", "they", "their",
"need", "needs", "also", "only", "over", "under", "more",
}
tokens = set(re.findall(r"[a-zA-Z0-9_]{3,}", str(text or "").lower()))
return {token for token in tokens if token not in stopwords}
def _split_markdown_sections(self, markdown: str) -> list[dict[str, str]]:
text = str(markdown or "").strip()
if not text:
return []
sections: list[dict[str, str]] = []
heading = ""
body_lines: list[str] = []
for line in text.splitlines():
match = re.match(r"^\s{0,3}(#{2,4})\s+(.*\S)\s*$", line)
if match:
body = "\n".join(body_lines).strip()
if heading or body:
sections.append({"heading": heading, "body": body})
heading = match.group(2).strip()
body_lines = []
continue
body_lines.append(line)
body = "\n".join(body_lines).strip()
if heading or body:
sections.append({"heading": heading, "body": body})
if not sections:
return [{"heading": "", "body": text}]
return sections
def _select_relevant_markdown_sections(
self,
markdown: str,
*,
query: str,
max_sections: int,
max_chars: int,
) -> list[dict[str, str]]:
sections = self._split_markdown_sections(markdown)
if not sections:
return []
query_tokens = self._memory_keywords(query)
scored: list[tuple[float, int, dict[str, str]]] = []
for index, section in enumerate(sections):
heading = section.get("heading", "")
body = section.get("body", "")
combined = f"{heading}\n{body}".strip()
if not combined:
continue
heading_tokens = self._memory_keywords(heading)
body_tokens = self._memory_keywords(body)
overlap_heading = len(query_tokens & heading_tokens)
overlap_body = len(query_tokens & body_tokens)
score = overlap_heading * 3 + overlap_body
lowered = combined.lower()
if any(token in lowered for token in ("checklist", "warning", "watchout", "gotcha", "risk")):
score += 0.5
if not query_tokens and index == 0:
score += 0.5
if score <= 0:
continue
scored.append((score, -index, section))
if not scored:
return []
scored.sort(reverse=True)
selected: list[dict[str, str]] = []
used_chars = 0
for _, _, section in scored:
heading = str(section.get("heading", "")).strip()
body = str(section.get("body", "")).strip()
snippet = body
projected = used_chars + len(heading) + len(snippet)
if projected > max_chars:
remaining = max_chars - used_chars - len(heading) - 32
if remaining <= 80:
continue
snippet = snippet[:remaining].rstrip() + "\n[memory section truncated]"
selected.append({"heading": heading, "body": snippet})
used_chars += len(heading) + len(snippet)
if len(selected) >= max_sections or used_chars >= max_chars:
break
return selected
def _render_selected_sections(self, title: str, sections: list[dict[str, str]]) -> str:
lines = [f"## {title}"]
for section in sections:
heading = str(section.get("heading", "")).strip()
body = str(section.get("body", "")).strip()
if heading:
lines.append(f"### {heading}")
if body:
lines.append(body)
return "\n".join(lines).strip()
def _parse_json_object(self, raw: str) -> dict[str, Any]:
text = str(raw or "").strip()
if text.startswith("```"):
parts = text.split("\n", 1)
text = parts[1] if len(parts) == 2 else text[3:]
if text.endswith("```"):
text = text[:-3]
text = text.strip()
try:
data = json.loads(text)
return data if isinstance(data, dict) else {}
except Exception:
start = text.find("{")
end = text.rfind("}")
if start >= 0 and end > start:
try:
data = json.loads(text[start : end + 1])
return data if isinstance(data, dict) else {}
except Exception:
return {}
return {}
def _normalize_memory_entries(self, values: Any) -> list[str]:
entries: list[str] = []
if not isinstance(values, list):
return entries
for item in values:
if isinstance(item, str):
text = item.strip()
if text:
entries.append(text)
continue
if not isinstance(item, dict):
continue
title = str(item.get("title", "") or "").strip()
content = str(item.get("content", "") or "").strip()
if not content:
continue
entry = f"### {title}\n{content}" if title else content
entries.append(entry.strip())
return entries
def _append_unique_memory_entry(self, entry: str, *, project_id: str | None) -> bool:
normalized_entry = " ".join(str(entry or "").split()).strip().lower()
if not normalized_entry:
return False
existing = self.markdown_store.load_visible_text(project_id)
if normalized_entry in " ".join(existing.split()).strip().lower():
return False
self.markdown_store.append_visible_entry(entry, project_id)
return True
async def _get_visible_session_transcript(self, session_id: str) -> list[dict[str, Any]]:
if not self.store:
return []
transcript = await self.store.get_session_transcript(session_id)
if not transcript:
return []
compaction = await self.store.get_latest_session_compaction(session_id)
boundary_message_id = compaction.source_boundary_message_id if compaction else ""
return self._slice_transcript_from_boundary(transcript, boundary_message_id)
def _render_session_parts(self, parts: list[SessionPartRecord]) -> str:
rendered_parts: list[str] = []
for part in parts:
payload = dict(part.payload)
if part.part_type == "text":
text = str(payload.get("text", "")).strip()
if text:
rendered_parts.append(text)
continue
if part.part_type == "subtask_result":
title = payload.get("task_title") or payload.get("child_session_id") or "child task"
summary = str(payload.get("summary", "")).strip()
artifacts = payload.get("artifacts") or {}
lines = [f"Child session result: {title}"]
if summary:
lines.append(summary)
artifact_lines = self._format_artifact_lines(artifacts)
if artifact_lines:
lines.append("Artifacts:")
lines.extend(f"- {line}" for line in artifact_lines)
rendered_parts.append("\n".join(lines))
continue
if part.part_type == "task_result":
title = payload.get("task_title") or payload.get("task_id") or "task"
outcome = str(payload.get("summary", "")).strip()
rendered_parts.append(f"Task result: {title}\n{outcome}".strip())
continue
if part.part_type == "tool_output":
name = payload.get("tool_name", "tool")
output = str(payload.get("output", "")).strip()
rendered_parts.append(f"Tool output [{name}]\n{output}".strip())
continue
if part.part_type == "tool_result":
name = payload.get("tool_name", "tool")
output = payload.get("result", {})
if not isinstance(output, str):
output = json.dumps(output, ensure_ascii=False, default=str)
rendered_parts.append(f"Tool result [{name}]\n{str(output).strip()}".strip())
continue
if part.part_type == "tool_call":
name = payload.get("tool_name", "tool")
arguments = payload.get("arguments", {})
rendered_parts.append(
f"Tool call [{name}]\n{json.dumps(arguments, ensure_ascii=False, default=str)}".strip()
)
continue
text = str(payload.get("text", "")).strip()
if text:
rendered_parts.append(text)
return "\n\n".join(rendered_parts).strip()
def _filter_prompt_history_items(
self,
visible_items: list[dict[str, Any]],
*,
include_latest_user_turn: bool,
) -> list[dict[str, Any]]:
filtered = [
item
for item in list(visible_items)
if not self._is_child_session_seed_item(item)
]
if include_latest_user_turn:
return filtered
for idx in range(len(filtered) - 1, -1, -1):
item = filtered[idx]
message = item.get("message")
if getattr(message, "summary_flag", False):
continue
role = str(getattr(message, "role", "") or "").strip().lower()
if role == "user":
return [*filtered[:idx], *filtered[idx + 1 :]]
break
return filtered
def _is_child_session_seed_item(self, item: dict[str, Any]) -> bool:
message = item.get("message")
if message is None or getattr(message, "summary_flag", False):
return False
if str(getattr(message, "role", "") or "").strip().lower() != "user":
return False
metadata = dict(getattr(message, "metadata", {}) or {})
return str(metadata.get("kind", "") or "").strip().lower() == "child_session_seed"
async def build_session_history_messages(
self,
session_id: str,
*,
include_latest_user_turn: bool = True,
) -> list[dict[str, Any]]:
visible_items = await self._get_visible_session_transcript(session_id)
visible_items = self._filter_prompt_history_items(
visible_items,
include_latest_user_turn=include_latest_user_turn,
)
messages: list[dict[str, Any]] = []
for item in visible_items:
message = item["message"]
content = self._render_session_parts(item["parts"])
if not content:
continue
role = "user" if message.role == "user" else "assistant"
messages.append({"role": role, "content": content})
return messages
async def build_session_history_tail_messages(
self,
session_id: str,
*,
include_latest_user_turn: bool = True,
) -> list[dict[str, Any]]:
return await self.build_session_history_messages(
session_id,
include_latest_user_turn=include_latest_user_turn,
)
async def build_session_prompt_context(
self,
session_id: str,
*,
include_latest_user_turn: bool = True,
) -> str:
visible_items = await self._get_visible_session_transcript(session_id)
visible_items = self._filter_prompt_history_items(
visible_items,
include_latest_user_turn=include_latest_user_turn,
)
session_memory = await self.build_session_memory_context(session_id)
blocks: list[str] = []
for item in visible_items:
rendered = self._render_session_message(item["message"], item["parts"])
if rendered:
blocks.append(rendered)
parts: list[str] = []
if session_memory:
parts.append(session_memory)
combined = "\n\n".join(blocks).strip()
if combined:
parts.append(f"## Current Session History\n{combined}")
return "\n\n".join(parts)
async def _get_agent_transcript(
self,
*,
project_id: str,
session_id: str,
employee_id: str,
) -> list[dict[str, Any]]:
if not self.store or not session_id or not employee_id:
return []
transcript = await self.store.get_session_transcript(session_id)
if not transcript:
return []
session = await self.store.get_session(session_id)
session_employee_id = str((session.metadata or {}).get("employee_id", "")).strip() if session else ""
if session_employee_id and session_employee_id == employee_id:
return transcript
visible_items: list[dict[str, Any]] = []
for item in transcript:
metadata = dict(item["message"].metadata or {})
if str(metadata.get("employee_id", "")).strip() == employee_id:
visible_items.append(item)
return visible_items
async def _get_visible_agent_transcript(
self,
*,
project_id: str,
session_id: str,
employee_id: str,
) -> list[dict[str, Any]]:
if not self.store:
return []
transcript = await self._get_agent_transcript(
project_id=project_id,
session_id=session_id,
employee_id=employee_id,
)
if not transcript:
return []
compaction = await self.store.get_latest_agent_compaction(
project_id=project_id,
session_id=session_id,
employee_id=employee_id,
)
boundary_message_id = compaction.source_boundary_message_id if compaction else ""
return self._slice_transcript_from_boundary(transcript, boundary_message_id)
async def build_employee_memory_context(
self,
*,
project_id: str | None,
session_id: str | None,
employee_id: str,
role_id: str = "",
) -> str:
if not self.store or not employee_id:
return ""
pid = self._resolve_project_id(project_id)
snapshot = await self.store.get_agent_memory_snapshot(
project_id=pid,
employee_id=employee_id,
memory_kind="final",
memory_scope="project",
)
if snapshot is None:
legacy_final = await self.store.get_agent_memory_snapshot(
project_id=pid,
employee_id=employee_id,
memory_kind="final",
memory_scope="session",
)
if legacy_final is not None:
snapshot = await self._migrate_legacy_project_final_snapshot(legacy_final)
if snapshot is None and session_id:
snapshot = await self.store.get_agent_memory_snapshot(
project_id=pid,
session_id=session_id,
employee_id=employee_id,
memory_kind="final",
memory_scope="session",
)
if snapshot is None and session_id:
snapshot = await self.store.get_agent_memory_snapshot(
project_id=pid,
session_id=session_id,
employee_id=employee_id,
memory_kind="process",
memory_scope="session",
)
if not snapshot or not snapshot.memory_text.strip():
return ""
if snapshot.memory_kind == "final" and snapshot.memory_scope == "project":
title = "## Employee Project Memory"
elif snapshot.memory_kind == "final":
title = "## Employee Final Memory"
else:
title = "## Employee Process Memory"
resolved_role = role_id or snapshot.role_id
header_lines = [title, f"- Employee ID: {employee_id}"]
if resolved_role:
header_lines.append(f"- Role: {resolved_role}")
header = "\n".join(header_lines)
return f"{header}\n\n{snapshot.memory_text.strip()}"
async def _migrate_legacy_project_final_snapshot(
self,
snapshot: AgentMemorySnapshotRecord,
) -> AgentMemorySnapshotRecord:
if not self.store or snapshot.memory_scope == "project":
return snapshot
migrated = AgentMemorySnapshotRecord(
project_id=snapshot.project_id,
session_id="",
employee_id=snapshot.employee_id,
role_id=snapshot.role_id,
memory_scope="project",
memory_kind=snapshot.memory_kind,
summary_message_id=snapshot.summary_message_id,
source_boundary_message_id=snapshot.source_boundary_message_id,
summary_text=snapshot.summary_text,
memory_text=snapshot.memory_text,
metadata={
**dict(snapshot.metadata or {}),
"migrated_from_session_id": snapshot.session_id,
},
)
await self.store.save_agent_memory_snapshot(migrated)
return migrated
async def build_employee_history_tail_messages(
self,
*,
project_id: str | None,
session_id: str | None,
employee_id: str,
) -> list[dict[str, Any]]:
if not session_id or not employee_id:
return []
pid = self._resolve_project_id(project_id)
visible_items = await self._get_visible_agent_transcript(
project_id=pid,
session_id=session_id,
employee_id=employee_id,
)
messages: list[dict[str, Any]] = []
for item in visible_items:
message = item["message"]
content = self._render_session_parts(item["parts"])
if not content:
continue
role = "user" if message.role == "user" else "assistant"
messages.append({"role": role, "content": content})
return messages
async def build_agent_memory_context(self, task: Any, role_id: str) -> str:
project_id = getattr(task, "project_id", None) or (self.project_id or "default")
session_id = getattr(task, "session_id", None)
assignment = dict(getattr(task, "metadata", {}).get("employee_assignment", {}) or {})
employee_id = str(
assignment.get("employee_id")
or getattr(task, "metadata", {}).get("employee_id", "")
or ""
).strip()
return await self.build_employee_memory_context(
project_id=project_id,
session_id=session_id,
employee_id=employee_id,
role_id=role_id or str(assignment.get("role_id", "")).strip(),
)
def build_external_prompt_context(self, task: Any, role_id: str, memory_ctx: str, comm_ctx: dict[str, Any] | None = None) -> str:
parts = [memory_ctx] if memory_ctx else []
if comm_ctx:
inbox = list(comm_ctx.get("inbox", []))
annotations = list(comm_ctx.get("annotations", []))
if inbox:
parts.append(
"## Inbox\n" +
"\n".join(
f"- From {item.get('from_agent', '')}: {item.get('subject', '')} :: {item.get('body', '')}"
for item in inbox
)
)
if annotations:
parts.append(
"## Task Annotations\n" +
"\n".join(
f"- {item.get('from', '')}: {item.get('body', '')}"
for item in annotations
)
)
if getattr(task, "metadata", {}).get("handoff_context"):
parts.append(f"## Handoff Context\n{getattr(task, 'metadata', {}).get('handoff_context', '')}")
return "\n\n".join(part for part in parts if part)
def _render_session_message(self, message: SessionMessageRecord, parts: list[SessionPartRecord]) -> str:
rendered = self._render_session_parts(parts)
if not rendered:
return ""
role = "User" if message.role == "user" else ("Summary" if message.summary_flag else "Assistant")
return f"{role}:\n{rendered}"
def _compact_artifacts(self, artifacts: dict[str, Any]) -> dict[str, Any]:
compact: dict[str, Any] = {}
for key, value in artifacts.items():
if isinstance(value, str):
compact[key] = value
elif isinstance(value, (int, float, bool)) or value is None:
compact[key] = value
elif isinstance(value, list):
compact[key] = [self._normalize_artifact_value(item) for item in value]
elif isinstance(value, dict):
compact[key] = {k: self._normalize_artifact_value(v) for k, v in value.items()}
else:
compact[key] = str(value)
return compact
def _normalize_artifact_value(self, value: Any) -> Any:
if isinstance(value, (str, int, float, bool)) or value is None:
return value
if isinstance(value, list):
return [self._normalize_artifact_value(item) for item in value]
if isinstance(value, dict):
return {k: self._normalize_artifact_value(v) for k, v in value.items()}
return str(value)
def _format_artifact_lines(self, artifacts: dict[str, Any]) -> list[str]:
lines: list[str] = []
for key, value in artifacts.items():
if isinstance(value, list):
lines.append(f"{key}: {', '.join(str(item) for item in value)}")
elif isinstance(value, dict):
inner = ", ".join(f"{k}={v}" for k, v in value.items())
lines.append(f"{key}: {inner}")
else:
lines.append(f"{key}: {value}")
return lines
def get_compression_prompt(self, messages: list[dict[str, Any]], existing_memory: str) -> str:
"""Create a prompt to compress conversation history into memory."""
msg_text = "\n".join(
f"[{m.get('role', '?')}]: {m.get('content', '')}"
for m in messages
)
return (
"You are a memory compressor. Summarize the following conversation into "
"key facts, decisions, and learnings. Preserve important technical details, "
"user preferences, and outcomes. Be concise but comprehensive. "
"If the source conversation is large, aggressively deduplicate and abstract instead of copying verbatim.\n\n"
f"## Existing Memory\n{existing_memory}\n\n"
f"## New Conversation\n{msg_text}\n\n"
"Write the updated memory as a structured markdown document. "
"Merge new information with existing memory. Remove duplicates."
)