Преглед на файлове

feat: add memory service

Jax Docker преди 1 месец
родител
ревизия
c24a325fb7
променени са 30 файла, в които са добавени 766 реда и са изтрити 0 реда
  1. 28 0
      README.md
  2. 27 0
      deployments/docker/docker-compose.yml
  3. 10 0
      libs/core-domain/src/core_domain/__init__.py
  4. 35 0
      libs/core-domain/src/core_domain/memory_contracts.py
  5. 1 0
      pyproject.toml
  6. 27 0
      services/api-gateway/app/api/routes.py
  7. 1 0
      services/api-gateway/app/bootstrap/settings.py
  8. 1 0
      services/api-gateway/app/infrastructure/proxy.py
  9. 36 0
      services/memory-service/alembic.ini
  10. 41 0
      services/memory-service/alembic/env.py
  11. 1 0
      services/memory-service/alembic/versions/.gitkeep
  12. 73 0
      services/memory-service/alembic/versions/20260425_0001_init_memory_models.py
  13. 1 0
      services/memory-service/app/__init__.py
  14. 1 0
      services/memory-service/app/api/__init__.py
  15. 81 0
      services/memory-service/app/api/routes.py
  16. 1 0
      services/memory-service/app/application/__init__.py
  17. 84 0
      services/memory-service/app/application/services.py
  18. 1 0
      services/memory-service/app/bootstrap/__init__.py
  19. 17 0
      services/memory-service/app/bootstrap/app.py
  20. 8 0
      services/memory-service/app/bootstrap/settings.py
  21. 1 0
      services/memory-service/app/db/__init__.py
  22. 5 0
      services/memory-service/app/db/models/__init__.py
  23. 27 0
      services/memory-service/app/db/models/memory_item.py
  24. 29 0
      services/memory-service/app/db/session.py
  25. 1 0
      services/memory-service/app/domain/__init__.py
  26. 138 0
      services/memory-service/app/domain/repositories.py
  27. 3 0
      services/memory-service/app/main.py
  28. 1 0
      services/memory-service/app/schemas/__init__.py
  29. 61 0
      services/memory-service/app/schemas/memory.py
  30. 25 0
      services/memory-service/pyproject.toml

+ 28 - 0
README.md

@@ -17,6 +17,7 @@
 - `workflow-service`
 - `runtime-service`
 - `agent-service`
+- `memory-service`
 - `tool-service`
 
 每个服务都提供了最小 `FastAPI` 启动入口和健康检查接口,数据库相关服务也已经带上了 `SQLAlchemy` 模型骨架与 Alembic 目录。
@@ -47,6 +48,7 @@ pip install -e .\services\session-service
 pip install -e .\services\workflow-service
 pip install -e .\services\runtime-service
 pip install -e .\services\agent-service
+pip install -e .\services\memory-service
 pip install -e .\services\tool-service
 ```
 
@@ -247,6 +249,30 @@ Invoke-RestMethod -Method Post `
 
 Through `api-gateway`, use `/gateway/agents/**`.
 
+## Memory Service APIs
+
+`memory-service` stores scoped memories for tenants, users, sessions, agents, and teams. The first version uses database text search so it works without vector infrastructure; pgvector can be added later behind the same API.
+
+Create a memory:
+
+```powershell
+Invoke-RestMethod -Method Post `
+  -Uri http://127.0.0.1:8008/memories `
+  -ContentType "application/json" `
+  -Body '{"tenant_id":"t1","scope_type":"session","scope_id":"session-id","memory_type":"fact","content_text":"User prefers concise answers.","importance_score":80}'
+```
+
+Search memories:
+
+```powershell
+Invoke-RestMethod -Method Post `
+  -Uri http://127.0.0.1:8008/memories/search `
+  -ContentType "application/json" `
+  -Body '{"tenant_id":"t1","query":"concise","scope_type":"session","scope_id":"session-id","limit":5}'
+```
+
+Through `api-gateway`, use `/gateway/memories/**`.
+
 Execute an agent run without calling an external model:
 
 ```powershell
@@ -522,6 +548,7 @@ $env:AGENT_PLATFORM_SMOKE_RUNTIME_URL="http://127.0.0.1:8000/gateway/runtime"
 - `/gateway/sessions/**` -> `session-service /sessions/**`
 - `/gateway/runtime/**` -> `runtime-service /runtime/**`
 - `/gateway/agents/**` -> `agent-service /agents/**`
+- `/gateway/memories/**` -> `memory-service /memories/**`
 - `/gateway/tools/**` -> `tool-service /tools/**`
 - `/gateway/models/**` -> `model-gateway-service /models/**`
 - `/gateway/code/**` -> `code-runner-service /code/**`
@@ -754,6 +781,7 @@ Important notes:
 
 - `workflow-service`, `session-service`, `runtime-service`, `tool-service`, and `api-gateway` use SQLite files mounted under `/data`
 - `agent-service` stores agent definitions, prompt/config versions, and agent run records under `/data`
+- `memory-service` stores scoped memories under `/data`; move it to PostgreSQL before enabling high-volume memory writes
 - `agent-worker` has no exposed port and can be scaled independently; set `AGENT_PLATFORM_AGENT_WORKER_DRY_RUN=true` for no-key local smoke runs
 - `runtime-worker` has no exposed port and can be scaled independently; prefer PostgreSQL for real multi-worker write concurrency
 - `runtime-service` automatically resolves internal URLs to `workflow-service`, `tool-service`, `model-gateway-service`, and `code-runner-service`

+ 27 - 0
deployments/docker/docker-compose.yml

@@ -145,6 +145,26 @@ services:
       model-gateway-service:
         condition: service_started
 
+  memory-service:
+    build:
+      context: ../..
+      dockerfile: deployments/docker/python-service.Dockerfile
+      args:
+        SERVICE_PATH: services/memory-service
+    container_name: agent-platform-memory-service
+    command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8008"]
+    environment:
+      AGENT_PLATFORM_DATABASE_URL: sqlite:////data/memory_service.db
+    ports:
+      - "8008:8008"
+    volumes:
+      - memory_service_data:/data
+    healthcheck:
+      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8008/memories/health').read()"]
+      interval: 15s
+      timeout: 5s
+      retries: 5
+
   runtime-service:
     build:
       context: ../..
@@ -160,6 +180,7 @@ services:
       AGENT_PLATFORM_MODEL_GATEWAY_SERVICE_URL: http://model-gateway-service:8005
       AGENT_PLATFORM_CODE_RUNNER_SERVICE_URL: http://code-runner-service:8006
       AGENT_PLATFORM_AGENT_SERVICE_URL: http://agent-service:8007
+      AGENT_PLATFORM_MEMORY_SERVICE_URL: http://memory-service:8008
     ports:
       - "8003:8003"
     volumes:
@@ -175,6 +196,8 @@ services:
         condition: service_started
       agent-service:
         condition: service_started
+      memory-service:
+        condition: service_started
     healthcheck:
       test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8003/runtime/health').read()"]
       interval: 15s
@@ -225,6 +248,7 @@ services:
       AGENT_PLATFORM_MODEL_GATEWAY_SERVICE_URL: http://model-gateway-service:8005
       AGENT_PLATFORM_CODE_RUNNER_SERVICE_URL: http://code-runner-service:8006
       AGENT_PLATFORM_AGENT_SERVICE_URL: http://agent-service:8007
+      AGENT_PLATFORM_MEMORY_SERVICE_URL: http://memory-service:8008
       AGENT_PLATFORM_AUTH_REQUIRED: ${AGENT_PLATFORM_AUTH_REQUIRED:-false}
     ports:
       - "8000:8000"
@@ -245,6 +269,8 @@ services:
         condition: service_started
       agent-service:
         condition: service_started
+      memory-service:
+        condition: service_started
     healthcheck:
       test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health').read()"]
       interval: 15s
@@ -254,6 +280,7 @@ services:
 volumes:
   api_gateway_data:
   agent_service_data:
+  memory_service_data:
   workflow_service_data:
   session_service_data:
   runtime_service_data:

+ 10 - 0
libs/core-domain/src/core_domain/__init__.py

@@ -22,6 +22,12 @@ from .model_contracts import (
     ChatCompletionResponseContract,
     ChatMessageContract,
 )
+from .memory_contracts import (
+    MemoryItemContract,
+    MemoryScopeType,
+    MemorySearchResultContract,
+    MemoryStatus,
+)
 from .runtime_contracts import (
     InitialNodeContract,
     NodeRunContract,
@@ -59,6 +65,10 @@ __all__ = [
     "ChatCompletionResponseContract",
     "ChatMessageContract",
     "InitialNodeContract",
+    "MemoryItemContract",
+    "MemoryScopeType",
+    "MemorySearchResultContract",
+    "MemoryStatus",
     "NodeExecutionContextContract",
     "NodeExecutionRequestContract",
     "NodeExecutionResultContract",

+ 35 - 0
libs/core-domain/src/core_domain/memory_contracts.py

@@ -0,0 +1,35 @@
+from datetime import datetime
+from typing import Literal
+
+from pydantic import BaseModel, Field
+
+from core_shared import JSONValue
+
+
+MemoryStatus = Literal["active", "archived", "deleted"]
+MemoryScopeType = Literal["tenant", "user", "session", "agent", "team"]
+
+
+class MemoryItemContract(BaseModel):
+    id: str
+    tenant_id: str
+    scope_type: MemoryScopeType
+    scope_id: str
+    memory_type: str
+    content_text: str
+    content_json: dict[str, JSONValue] | None = None
+    metadata_json: dict[str, JSONValue] = Field(default_factory=dict)
+    owner_agent_id: str | None = None
+    user_id: str | None = None
+    session_id: str | None = None
+    source_ref: str | None = None
+    importance_score: int
+    status: MemoryStatus
+    last_accessed_time: datetime | None = None
+    expires_time: datetime | None = None
+    created_time: datetime
+
+
+class MemorySearchResultContract(BaseModel):
+    item: MemoryItemContract
+    score: float

+ 1 - 0
pyproject.toml

@@ -8,6 +8,7 @@ members = [
   "services/api-gateway",
   "services/agent-service",
   "services/code-runner-service",
+  "services/memory-service",
   "services/model-gateway-service",
   "services/session-service",
   "services/workflow-service",

+ 27 - 0
services/api-gateway/app/api/routes.py

@@ -157,6 +157,12 @@ def build_proxy_targets(settings: ApiGatewaySettings) -> dict[ProxyServiceName,
             path_prefix="/agents",
             health_path="/agents/health",
         ),
+        "memory-service": ProxyTarget(
+            service_name="memory-service",
+            base_url=settings.memory_service_url,
+            path_prefix="/memories",
+            health_path="/memories/health",
+        ),
     }
 
 
@@ -260,6 +266,27 @@ async def proxy_agent_service(
     )
 
 
+@router.api_route(
+    "/gateway/memories",
+    methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
+)
+@router.api_route(
+    "/gateway/memories/{path:path}",
+    methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
+)
+async def proxy_memory_service(
+    request: Request,
+    path: str = "",
+    settings: ApiGatewaySettings = Depends(get_gateway_settings),
+    proxy: ServiceProxy = Depends(get_service_proxy),
+) -> Response:
+    return await proxy.forward(
+        request=request,
+        target=build_proxy_targets(settings)["memory-service"],
+        path=path,
+    )
+
+
 @router.api_route(
     "/gateway/tools",
     methods=["GET", "POST", "PUT", "PATCH", "DELETE"],

+ 1 - 0
services/api-gateway/app/bootstrap/settings.py

@@ -12,6 +12,7 @@ class ApiGatewaySettings(ServiceSettings):
     model_gateway_service_url: str = "http://127.0.0.1:8005"
     code_runner_service_url: str = "http://127.0.0.1:8006"
     agent_service_url: str = "http://127.0.0.1:8007"
+    memory_service_url: str = "http://127.0.0.1:8008"
     proxy_timeout_seconds: float = 30.0
     downstream_health_timeout_seconds: float = 2.0
     auth_required: bool = False

+ 1 - 0
services/api-gateway/app/infrastructure/proxy.py

@@ -16,6 +16,7 @@ ProxyServiceName = Literal[
     "model-gateway-service",
     "code-runner-service",
     "agent-service",
+    "memory-service",
 ]
 
 

+ 36 - 0
services/memory-service/alembic.ini

@@ -0,0 +1,36 @@
+[alembic]
+script_location = alembic
+prepend_sys_path = .
+sqlalchemy.url = sqlite:///./memory_service.db
+
+[loggers]
+keys = root,sqlalchemy,alembic
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARN
+handlers = console
+
+[logger_sqlalchemy]
+level = WARN
+handlers =
+qualname = sqlalchemy.engine
+
+[logger_alembic]
+level = INFO
+handlers = console
+qualname = alembic
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(levelname)-5.5s [%(name)s] %(message)s

+ 41 - 0
services/memory-service/alembic/env.py

@@ -0,0 +1,41 @@
+from logging.config import fileConfig
+
+from alembic import context
+from sqlalchemy import engine_from_config, pool
+
+from app.db.models import Base
+
+config = context.config
+
+if config.config_file_name is not None:
+    fileConfig(config.config_file_name)
+
+target_metadata = Base.metadata
+
+
+def run_migrations_offline() -> None:
+    url = config.get_main_option("sqlalchemy.url")
+    context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
+
+    with context.begin_transaction():
+        context.run_migrations()
+
+
+def run_migrations_online() -> None:
+    connectable = engine_from_config(
+        config.get_section(config.config_ini_section, {}),
+        prefix="sqlalchemy.",
+        poolclass=pool.NullPool,
+    )
+
+    with connectable.connect() as connection:
+        context.configure(connection=connection, target_metadata=target_metadata)
+
+        with context.begin_transaction():
+            context.run_migrations()
+
+
+if context.is_offline_mode():
+    run_migrations_offline()
+else:
+    run_migrations_online()

+ 1 - 0
services/memory-service/alembic/versions/.gitkeep

@@ -0,0 +1 @@
+

+ 73 - 0
services/memory-service/alembic/versions/20260425_0001_init_memory_models.py

@@ -0,0 +1,73 @@
+"""init memory models
+
+Revision ID: 20260425_0001
+Revises:
+Create Date: 2026-04-25 13:20:00
+"""
+
+from collections.abc import Sequence
+
+from alembic import op
+import sqlalchemy as sa
+
+
+revision: str = "20260425_0001"
+down_revision: str | None = None
+branch_labels: Sequence[str] | None = None
+depends_on: Sequence[str] | None = None
+
+
+def upgrade() -> None:
+    op.create_table(
+        "memory_item",
+        sa.Column("scope_type", sa.String(length=32), nullable=False),
+        sa.Column("scope_id", sa.String(length=64), nullable=False),
+        sa.Column("memory_type", sa.String(length=64), nullable=False),
+        sa.Column("content_text", sa.Text(), nullable=False),
+        sa.Column("content_json", sa.JSON(), nullable=True),
+        sa.Column("metadata_json", sa.JSON(), nullable=False),
+        sa.Column("owner_agent_id", sa.String(length=36), nullable=True),
+        sa.Column("user_id", sa.String(length=36), nullable=True),
+        sa.Column("session_id", sa.String(length=36), nullable=True),
+        sa.Column("source_ref", sa.String(length=256), nullable=True),
+        sa.Column("importance_score", sa.Integer(), nullable=False),
+        sa.Column("status", sa.String(length=32), nullable=False),
+        sa.Column("last_accessed_time", sa.DateTime(), nullable=True),
+        sa.Column("expires_time", sa.DateTime(), nullable=True),
+        sa.Column("id", sa.String(length=36), nullable=False),
+        sa.Column("tenant_id", sa.String(length=36), nullable=False),
+        sa.Column("created_by", sa.String(length=36), nullable=True),
+        sa.Column("updated_by", sa.String(length=36), nullable=True),
+        sa.Column("created_time", sa.DateTime(), nullable=False),
+        sa.Column("updated_time", sa.DateTime(), nullable=False),
+        sa.Column("deleted_time", sa.DateTime(), nullable=True),
+        sa.Column("version", sa.Integer(), nullable=False),
+        sa.PrimaryKeyConstraint("id"),
+    )
+    op.create_index("ix_memory_item_memory_type", "memory_item", ["memory_type"], unique=False)
+    op.create_index("ix_memory_item_owner_agent_id", "memory_item", ["owner_agent_id"], unique=False)
+    op.create_index("ix_memory_item_scope_id", "memory_item", ["scope_id"], unique=False)
+    op.create_index("ix_memory_item_scope_type", "memory_item", ["scope_type"], unique=False)
+    op.create_index("ix_memory_item_session_id", "memory_item", ["session_id"], unique=False)
+    op.create_index("ix_memory_item_status", "memory_item", ["status"], unique=False)
+    op.create_index("ix_memory_item_tenant_id", "memory_item", ["tenant_id"], unique=False)
+    op.create_index("ix_memory_item_user_id", "memory_item", ["user_id"], unique=False)
+    op.create_index(
+        "ix_memory_item_search_scope",
+        "memory_item",
+        ["tenant_id", "scope_type", "scope_id", "status"],
+        unique=False,
+    )
+
+
+def downgrade() -> None:
+    op.drop_index("ix_memory_item_search_scope", table_name="memory_item")
+    op.drop_index("ix_memory_item_user_id", table_name="memory_item")
+    op.drop_index("ix_memory_item_tenant_id", table_name="memory_item")
+    op.drop_index("ix_memory_item_status", table_name="memory_item")
+    op.drop_index("ix_memory_item_session_id", table_name="memory_item")
+    op.drop_index("ix_memory_item_scope_type", table_name="memory_item")
+    op.drop_index("ix_memory_item_scope_id", table_name="memory_item")
+    op.drop_index("ix_memory_item_owner_agent_id", table_name="memory_item")
+    op.drop_index("ix_memory_item_memory_type", table_name="memory_item")
+    op.drop_table("memory_item")

+ 1 - 0
services/memory-service/app/__init__.py

@@ -0,0 +1 @@
+

+ 1 - 0
services/memory-service/app/api/__init__.py

@@ -0,0 +1 @@
+

+ 81 - 0
services/memory-service/app/api/routes.py

@@ -0,0 +1,81 @@
+from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlalchemy import text
+from sqlalchemy.orm import Session
+
+from core_domain import MemoryScopeType, MemoryStatus, ServiceHealth
+
+from app.application.services import MemoryApplicationService
+from app.db.session import get_db
+from app.domain.repositories import MemoryItemRepository
+from app.schemas.memory import (
+    MemoryCreateRequest,
+    MemoryResponse,
+    MemorySearchRequest,
+    MemorySearchResultResponse,
+    MemoryStatusUpdateRequest,
+)
+
+router = APIRouter()
+
+
+def get_memory_application_service(db: Session = Depends(get_db)) -> MemoryApplicationService:
+    return MemoryApplicationService(memory_repository=MemoryItemRepository(db))
+
+
+@router.get("/health", response_model=ServiceHealth)
+def health_check(db: Session = Depends(get_db)) -> ServiceHealth:
+    db.execute(text("SELECT 1"))
+    return ServiceHealth(service="memory-service", status="ok", database="ok")
+
+
+@router.post("", response_model=MemoryResponse)
+def create_memory(
+    payload: MemoryCreateRequest,
+    service: MemoryApplicationService = Depends(get_memory_application_service),
+) -> MemoryResponse:
+    entity = service.create_memory(payload)
+    return MemoryResponse.from_entity(entity)
+
+
+@router.get("", response_model=list[MemoryResponse])
+def list_memories(
+    tenant_id: str = Query(...),
+    scope_type: MemoryScopeType | None = Query(default=None),
+    scope_id: str | None = Query(default=None),
+    status: MemoryStatus | None = Query(default="active"),
+    limit: int = Query(default=100, ge=1, le=500),
+    service: MemoryApplicationService = Depends(get_memory_application_service),
+) -> list[MemoryResponse]:
+    return [
+        MemoryResponse.from_entity(item)
+        for item in service.list_memories(
+            tenant_id=tenant_id,
+            scope_type=scope_type,
+            scope_id=scope_id,
+            status=status,
+            limit=limit,
+        )
+    ]
+
+
+@router.post("/search", response_model=list[MemorySearchResultResponse])
+def search_memories(
+    payload: MemorySearchRequest,
+    service: MemoryApplicationService = Depends(get_memory_application_service),
+) -> list[MemorySearchResultResponse]:
+    return [
+        MemorySearchResultResponse.from_entity(item, score=score)
+        for item, score in service.search_memories(payload)
+    ]
+
+
+@router.patch("/{memory_id}/status", response_model=MemoryResponse)
+def update_memory_status(
+    memory_id: str,
+    payload: MemoryStatusUpdateRequest,
+    service: MemoryApplicationService = Depends(get_memory_application_service),
+) -> MemoryResponse:
+    entity = service.update_memory_status(memory_id=memory_id, payload=payload)
+    if entity is None:
+        raise HTTPException(status_code=404, detail=f"memory not found: {memory_id}")
+    return MemoryResponse.from_entity(entity)

+ 1 - 0
services/memory-service/app/application/__init__.py

@@ -0,0 +1 @@
+

+ 84 - 0
services/memory-service/app/application/services.py

@@ -0,0 +1,84 @@
+from datetime import datetime
+
+from core_domain import MemoryScopeType, MemoryStatus
+
+from app.db.models import MemoryItem
+from app.domain.repositories import MemoryItemRepository
+from app.schemas.memory import (
+    MemoryCreateRequest,
+    MemorySearchRequest,
+    MemoryStatusUpdateRequest,
+)
+
+
+class MemoryApplicationService:
+    def __init__(self, *, memory_repository: MemoryItemRepository) -> None:
+        self.memory_repository = memory_repository
+
+    def create_memory(self, payload: MemoryCreateRequest) -> MemoryItem:
+        return self.memory_repository.create(
+            tenant_id=payload.tenant_id,
+            scope_type=payload.scope_type,
+            scope_id=payload.scope_id,
+            memory_type=payload.memory_type,
+            content_text=payload.content_text,
+            content_json=payload.content_json,
+            metadata_json=payload.metadata_json,
+            owner_agent_id=payload.owner_agent_id,
+            user_id=payload.user_id,
+            session_id=payload.session_id,
+            source_ref=payload.source_ref,
+            importance_score=payload.importance_score,
+            expires_time=payload.expires_time,
+        )
+
+    def list_memories(
+        self,
+        *,
+        tenant_id: str,
+        scope_type: MemoryScopeType | None = None,
+        scope_id: str | None = None,
+        status: MemoryStatus | None = "active",
+        limit: int = 100,
+    ) -> list[MemoryItem]:
+        return self.memory_repository.list_by_scope(
+            tenant_id=tenant_id,
+            scope_type=scope_type,
+            scope_id=scope_id,
+            status=status,
+            limit=limit,
+        )
+
+    def search_memories(self, payload: MemorySearchRequest) -> list[tuple[MemoryItem, float]]:
+        items = self.memory_repository.search(
+            tenant_id=payload.tenant_id,
+            query=payload.query,
+            scope_type=payload.scope_type,
+            scope_id=payload.scope_id,
+            owner_agent_id=payload.owner_agent_id,
+            user_id=payload.user_id,
+            session_id=payload.session_id,
+            limit=payload.limit,
+        )
+        now = datetime.utcnow()
+        self.memory_repository.touch_many(memory_ids=[item.id for item in items], accessed_time=now)
+        return [(item, self._score(item=item, query=payload.query)) for item in items]
+
+    def update_memory_status(
+        self,
+        *,
+        memory_id: str,
+        payload: MemoryStatusUpdateRequest,
+    ) -> MemoryItem | None:
+        return self.memory_repository.update_status(
+            tenant_id=payload.tenant_id,
+            memory_id=memory_id,
+            status=payload.status,
+        )
+
+    def _score(self, *, item: MemoryItem, query: str) -> float:
+        lowered_content = item.content_text.lower()
+        lowered_query = query.lower()
+        exact_bonus = 1.0 if lowered_query in lowered_content else 0.0
+        importance_bonus = min(item.importance_score, 100) / 100
+        return round(exact_bonus + importance_bonus, 4)

+ 1 - 0
services/memory-service/app/bootstrap/__init__.py

@@ -0,0 +1 @@
+

+ 17 - 0
services/memory-service/app/bootstrap/app.py

@@ -0,0 +1,17 @@
+from fastapi import FastAPI
+
+from app.api.routes import router
+from app.bootstrap.settings import MemoryServiceSettings
+from app.db.session import build_session_factory
+
+
+def create_app() -> FastAPI:
+    settings = MemoryServiceSettings()
+    app = FastAPI(
+        title="agent-platform memory-service",
+        version="0.1.0",
+    )
+    app.state.settings = settings
+    app.state.session_factory = build_session_factory(settings)
+    app.include_router(router, prefix="/memories", tags=["memories"])
+    return app

+ 8 - 0
services/memory-service/app/bootstrap/settings.py

@@ -0,0 +1,8 @@
+from core_shared import ServiceSettings
+
+
+class MemoryServiceSettings(ServiceSettings):
+    service_name: str = "memory-service"
+    service_port: int = 8008
+    database_url: str = "sqlite:///./memory_service.db"
+    default_search_limit: int = 8

+ 1 - 0
services/memory-service/app/db/__init__.py

@@ -0,0 +1 @@
+

+ 5 - 0
services/memory-service/app/db/models/__init__.py

@@ -0,0 +1,5 @@
+from core_db import Base
+
+from .memory_item import MemoryItem
+
+__all__ = ["Base", "MemoryItem"]

+ 27 - 0
services/memory-service/app/db/models/memory_item.py

@@ -0,0 +1,27 @@
+from datetime import datetime
+
+from sqlalchemy import DateTime, Integer, String, Text
+from sqlalchemy.dialects.sqlite import JSON
+from sqlalchemy.orm import Mapped, mapped_column
+
+from core_db import AuditMixin, Base, TenantMixin, VersionMixin
+from core_shared import JSONValue
+
+
+class MemoryItem(TenantMixin, AuditMixin, VersionMixin, Base):
+    __tablename__ = "memory_item"
+
+    scope_type: Mapped[str] = mapped_column(String(32), index=True)
+    scope_id: Mapped[str] = mapped_column(String(64), index=True)
+    memory_type: Mapped[str] = mapped_column(String(64), default="fact", index=True)
+    content_text: Mapped[str] = mapped_column(Text)
+    content_json: Mapped[dict[str, JSONValue] | None] = mapped_column(JSON, nullable=True)
+    metadata_json: Mapped[dict[str, JSONValue]] = mapped_column(JSON, default=dict)
+    owner_agent_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
+    user_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
+    session_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
+    source_ref: Mapped[str | None] = mapped_column(String(256), nullable=True)
+    importance_score: Mapped[int] = mapped_column(Integer, default=0)
+    status: Mapped[str] = mapped_column(String(32), default="active", index=True)
+    last_accessed_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    expires_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)

+ 29 - 0
services/memory-service/app/db/session.py

@@ -0,0 +1,29 @@
+from collections.abc import Generator
+
+from fastapi import Request
+from sqlalchemy.orm import Session, sessionmaker
+
+from core_db import DatabaseSettings, create_engine_from_settings, create_session_factory
+
+from app.bootstrap.settings import MemoryServiceSettings
+
+
+def build_session_factory(
+    settings: MemoryServiceSettings | None = None,
+) -> sessionmaker[Session]:
+    resolved_settings = settings or MemoryServiceSettings()
+    db_settings = DatabaseSettings(
+        database_url=resolved_settings.database_url,
+        echo_sql=resolved_settings.echo_sql,
+    )
+    engine = create_engine_from_settings(db_settings)
+    return create_session_factory(engine)
+
+
+def get_db(request: Request) -> Generator[Session, None, None]:
+    session_factory: sessionmaker[Session] = request.app.state.session_factory
+    session = session_factory()
+    try:
+        yield session
+    finally:
+        session.close()

+ 1 - 0
services/memory-service/app/domain/__init__.py

@@ -0,0 +1 @@
+

+ 138 - 0
services/memory-service/app/domain/repositories.py

@@ -0,0 +1,138 @@
+from datetime import datetime
+
+from sqlalchemy import or_, select
+from sqlalchemy.orm import Session
+
+from core_domain import MemoryScopeType, MemoryStatus
+from core_shared import JSONValue
+
+from app.db.models import MemoryItem
+
+
+class MemoryItemRepository:
+    def __init__(self, db: Session) -> None:
+        self.db = db
+
+    def create(
+        self,
+        *,
+        tenant_id: str,
+        scope_type: MemoryScopeType,
+        scope_id: str,
+        memory_type: str,
+        content_text: str,
+        content_json: dict[str, JSONValue] | None,
+        metadata_json: dict[str, JSONValue],
+        owner_agent_id: str | None,
+        user_id: str | None,
+        session_id: str | None,
+        source_ref: str | None,
+        importance_score: int,
+        expires_time: datetime | None,
+    ) -> MemoryItem:
+        entity = MemoryItem(
+            tenant_id=tenant_id,
+            scope_type=scope_type,
+            scope_id=scope_id,
+            memory_type=memory_type,
+            content_text=content_text,
+            content_json=content_json,
+            metadata_json=metadata_json,
+            owner_agent_id=owner_agent_id,
+            user_id=user_id,
+            session_id=session_id,
+            source_ref=source_ref,
+            importance_score=importance_score,
+            status="active",
+            expires_time=expires_time,
+        )
+        self.db.add(entity)
+        self.db.commit()
+        self.db.refresh(entity)
+        return entity
+
+    def list_by_scope(
+        self,
+        *,
+        tenant_id: str,
+        scope_type: MemoryScopeType | None = None,
+        scope_id: str | None = None,
+        status: MemoryStatus | None = "active",
+        limit: int = 100,
+    ) -> list[MemoryItem]:
+        stmt = select(MemoryItem).where(MemoryItem.tenant_id == tenant_id)
+        if scope_type is not None:
+            stmt = stmt.where(MemoryItem.scope_type == scope_type)
+        if scope_id is not None:
+            stmt = stmt.where(MemoryItem.scope_id == scope_id)
+        if status is not None:
+            stmt = stmt.where(MemoryItem.status == status)
+        stmt = stmt.order_by(MemoryItem.created_time.desc()).limit(limit)
+        return list(self.db.scalars(stmt))
+
+    def search(
+        self,
+        *,
+        tenant_id: str,
+        query: str,
+        scope_type: MemoryScopeType | None,
+        scope_id: str | None,
+        owner_agent_id: str | None,
+        user_id: str | None,
+        session_id: str | None,
+        limit: int,
+    ) -> list[MemoryItem]:
+        now = datetime.utcnow()
+        pattern = f"%{query}%"
+        stmt = (
+            select(MemoryItem)
+            .where(MemoryItem.tenant_id == tenant_id)
+            .where(MemoryItem.status == "active")
+            .where(or_(MemoryItem.expires_time.is_(None), MemoryItem.expires_time > now))
+            .where(MemoryItem.content_text.like(pattern))
+        )
+        if scope_type is not None:
+            stmt = stmt.where(MemoryItem.scope_type == scope_type)
+        if scope_id is not None:
+            stmt = stmt.where(MemoryItem.scope_id == scope_id)
+        if owner_agent_id is not None:
+            stmt = stmt.where(MemoryItem.owner_agent_id == owner_agent_id)
+        if user_id is not None:
+            stmt = stmt.where(MemoryItem.user_id == user_id)
+        if session_id is not None:
+            stmt = stmt.where(MemoryItem.session_id == session_id)
+        stmt = stmt.order_by(MemoryItem.importance_score.desc(), MemoryItem.created_time.desc()).limit(
+            limit
+        )
+        return list(self.db.scalars(stmt))
+
+    def get_by_id(self, *, tenant_id: str, memory_id: str) -> MemoryItem | None:
+        stmt = (
+            select(MemoryItem)
+            .where(MemoryItem.tenant_id == tenant_id)
+            .where(MemoryItem.id == memory_id)
+        )
+        return self.db.scalar(stmt)
+
+    def touch_many(self, *, memory_ids: list[str], accessed_time: datetime) -> None:
+        if not memory_ids:
+            return
+        items = list(self.db.scalars(select(MemoryItem).where(MemoryItem.id.in_(memory_ids))))
+        for item in items:
+            item.last_accessed_time = accessed_time
+        self.db.commit()
+
+    def update_status(
+        self,
+        *,
+        tenant_id: str,
+        memory_id: str,
+        status: MemoryStatus,
+    ) -> MemoryItem | None:
+        entity = self.get_by_id(tenant_id=tenant_id, memory_id=memory_id)
+        if entity is None:
+            return None
+        entity.status = status
+        self.db.commit()
+        self.db.refresh(entity)
+        return entity

+ 3 - 0
services/memory-service/app/main.py

@@ -0,0 +1,3 @@
+from app.bootstrap.app import create_app
+
+app = create_app()

+ 1 - 0
services/memory-service/app/schemas/__init__.py

@@ -0,0 +1 @@
+

+ 61 - 0
services/memory-service/app/schemas/memory.py

@@ -0,0 +1,61 @@
+from datetime import datetime
+from typing import TYPE_CHECKING
+
+from pydantic import BaseModel, Field
+
+from core_domain import (
+    MemoryItemContract,
+    MemoryScopeType,
+    MemorySearchResultContract,
+    MemoryStatus,
+)
+from core_shared import JSONValue
+
+if TYPE_CHECKING:
+    from app.db.models import MemoryItem
+
+
+class MemoryCreateRequest(BaseModel):
+    tenant_id: str
+    scope_type: MemoryScopeType
+    scope_id: str
+    memory_type: str = "fact"
+    content_text: str
+    content_json: dict[str, JSONValue] | None = None
+    metadata_json: dict[str, JSONValue] = Field(default_factory=dict)
+    owner_agent_id: str | None = None
+    user_id: str | None = None
+    session_id: str | None = None
+    source_ref: str | None = None
+    importance_score: int = Field(default=0, ge=0, le=100)
+    expires_time: datetime | None = None
+
+
+class MemoryStatusUpdateRequest(BaseModel):
+    tenant_id: str
+    status: MemoryStatus
+
+
+class MemorySearchRequest(BaseModel):
+    tenant_id: str
+    query: str
+    scope_type: MemoryScopeType | None = None
+    scope_id: str | None = None
+    owner_agent_id: str | None = None
+    user_id: str | None = None
+    session_id: str | None = None
+    limit: int = Field(default=8, ge=1, le=100)
+
+
+class MemoryResponse(MemoryItemContract):
+    @classmethod
+    def from_entity(cls, entity: "MemoryItem") -> "MemoryResponse":
+        return cls.model_validate(entity, from_attributes=True)
+
+
+class MemorySearchResultResponse(MemorySearchResultContract):
+    item: MemoryResponse
+
+    @classmethod
+    def from_entity(cls, entity: "MemoryItem", score: float) -> "MemorySearchResultResponse":
+        return cls(item=MemoryResponse.from_entity(entity), score=score)

+ 25 - 0
services/memory-service/pyproject.toml

@@ -0,0 +1,25 @@
+[build-system]
+requires = ["setuptools>=68"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "memory-service"
+version = "0.1.0"
+description = "Memory service for agent platform."
+requires-python = ">=3.11"
+dependencies = [
+  "alembic>=1.13,<2.0",
+  "fastapi>=0.111,<1.0",
+  "uvicorn[standard]>=0.30,<1.0",
+  "pydantic>=2.7,<3.0",
+  "sqlalchemy>=2.0,<3.0",
+  "core-db",
+  "core-domain",
+  "core-shared",
+]
+
+[tool.setuptools]
+package-dir = {"" = "."}
+
+[tool.setuptools.packages.find]
+where = ["."]