diff --git a/mnemosyne/library/api/serializers.py b/mnemosyne/library/api/serializers.py
index b28ce9c..5562a6d 100644
--- a/mnemosyne/library/api/serializers.py
+++ b/mnemosyne/library/api/serializers.py
@@ -37,6 +37,7 @@ class LibrarySerializer(serializers.Serializer):
required=False, allow_blank=True, default=""
)
workspace_id = serializers.CharField(read_only=True)
+ managed_by = serializers.CharField(read_only=True)
created_at = serializers.DateTimeField(read_only=True)
@@ -193,6 +194,7 @@ class WorkspaceStatusSerializer(serializers.Serializer):
name = serializers.CharField()
library_type = serializers.CharField()
description = serializers.CharField(allow_blank=True)
+ managed_by = serializers.CharField(allow_null=True, required=False)
item_count = serializers.IntegerField()
chunk_count = serializers.IntegerField()
created_at = serializers.DateTimeField()
diff --git a/mnemosyne/library/api/views.py b/mnemosyne/library/api/views.py
index 0e8bd5e..d8f4fc9 100644
--- a/mnemosyne/library/api/views.py
+++ b/mnemosyne/library/api/views.py
@@ -17,6 +17,7 @@ from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from library.content_types import get_library_type_config
+from mcp_server.drf_auth import request_token_label
from .serializers import (
CollectionSerializer,
@@ -84,6 +85,28 @@ def library_list_create(request):
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
+ # Library.name is globally unique; reject collisions with a clean 409
+ # (uid + managed_by included so the caller can say who owns the name)
+ # instead of letting the unique-index save raise a 500.
+ try:
+ existing = Library.nodes.get(name=data["name"])
+ except Library.DoesNotExist:
+ existing = None
+ if existing is not None:
+ logger.warning(
+ "library_create name_conflict name=%s existing_uid=%s caller=%s",
+ data["name"], existing.uid, request.user.username,
+ )
+ return Response(
+ {
+ "detail": f"A library named '{data['name']}' already exists.",
+ "code": "name_conflict",
+ "uid": existing.uid,
+ "managed_by": existing.managed_by_display or None,
+ },
+ status=status.HTTP_409_CONFLICT,
+ )
+
# Populate defaults from content-type config if not provided
library_type = data["library_type"]
defaults = get_library_type_config(library_type)
@@ -92,6 +115,7 @@ def library_list_create(request):
name=data["name"],
library_type=library_type,
description=data.get("description", ""),
+ managed_by=request_token_label(request),
chunking_config=data.get("chunking_config") or defaults["chunking_config"],
embedding_instruction=(
data.get("embedding_instruction") or defaults["embedding_instruction"]
diff --git a/mnemosyne/library/api/workspaces.py b/mnemosyne/library/api/workspaces.py
index c7ee4c5..3de0afe 100644
--- a/mnemosyne/library/api/workspaces.py
+++ b/mnemosyne/library/api/workspaces.py
@@ -25,6 +25,7 @@ from rest_framework.response import Response
from library.content_types import get_library_type_config
from library.services.library_delete import delete_library_cascade
+from mcp_server.drf_auth import request_token_label
from .serializers import WorkspaceCreateSerializer, WorkspaceStatusSerializer
@@ -49,6 +50,7 @@ def _serialize_workspace(lib):
"name": lib.name,
"library_type": lib.library_type,
"description": lib.description or "",
+ "managed_by": lib.managed_by,
"item_count": item_count,
"chunk_count": chunk_count,
"created_at": lib.created_at,
@@ -104,6 +106,18 @@ def workspace_create(request):
},
status=status.HTTP_409_CONFLICT,
)
+ # Lazy backfill: pre-managed_by libraries pick up the label from
+ # the first idempotent re-POST. Null-only — an already-stamped
+ # library never changes manager.
+ if not existing.managed_by:
+ label = request_token_label(request)
+ if label:
+ existing.managed_by = label
+ existing.save()
+ logger.info(
+ "Backfilled managed_by=%s workspace_id=%s library_uid=%s",
+ label, existing.workspace_id, existing.uid,
+ )
logger.info(
"Workspace already exists workspace_id=%s library_uid=%s",
data["workspace_id"], existing.uid,
@@ -120,6 +134,7 @@ def workspace_create(request):
description=data.get("description", ""),
workspace_id=data["workspace_id"],
owner_username=request.user.username,
+ managed_by=request_token_label(request),
chunking_config=defaults["chunking_config"],
embedding_instruction=defaults["embedding_instruction"],
reranker_instruction=defaults["reranker_instruction"],
diff --git a/mnemosyne/library/management/commands/backfill_managed_by.py b/mnemosyne/library/management/commands/backfill_managed_by.py
new file mode 100644
index 0000000..02c6810
--- /dev/null
+++ b/mnemosyne/library/management/commands/backfill_managed_by.py
@@ -0,0 +1,111 @@
+"""One-off backfill of ``Library.managed_by`` for pre-existing libraries.
+
+``managed_by`` is stamped from the creating API token's name, so
+libraries created before the property existed have it null. This
+command labels them:
+
+* Workspace-scoped libraries get :func:`infer_legacy_manager`'s answer —
+ ``Kairos`` for ``kairos-mail-*`` workspace ids, ``Daedalus`` otherwise.
+* Global libraries that Spelunker ingested into (any ``IngestJob`` with
+ ``source="spelunker"``) get the Spelunker label.
+* Everything else stays null (hand-made in the web UI).
+
+Label flags let the operator match the *actual* production token names
+so backfilled rows render identically to newly stamped ones.
+
+Idempotent: only null ``managed_by`` is ever written, and the lazy fill
+in ``workspace_create`` is also null-only, so re-runs and later API
+traffic never overwrite these labels.
+"""
+
+from __future__ import annotations
+
+import logging
+
+from django.core.management.base import BaseCommand, CommandError
+
+from library.models import IngestJob
+
+logger = logging.getLogger(__name__)
+
+
+class Command(BaseCommand):
+ help = (
+ "Backfill Library.managed_by for libraries created before the "
+ "property existed. Workspace libraries are labelled by inference "
+ "(kairos-mail-* → Kairos, else Daedalus); global libraries with "
+ "Spelunker ingest jobs get the Spelunker label."
+ )
+
+ def add_arguments(self, parser):
+ parser.add_argument(
+ "--daedalus-label", default="Daedalus",
+ help="Label for non-Kairos workspace libraries (default: Daedalus).",
+ )
+ parser.add_argument(
+ "--kairos-label", default="Kairos",
+ help="Label for kairos-mail-* workspace libraries (default: Kairos).",
+ )
+ parser.add_argument(
+ "--spelunker-label", default="Spelunker",
+ help="Label for global libraries with Spelunker ingest jobs "
+ "(default: Spelunker).",
+ )
+ parser.add_argument(
+ "--dry-run", action="store_true",
+ help="Report what would be labelled, don't persist.",
+ )
+
+ def handle(self, *args, **options):
+ try:
+ from library.models import Library, infer_legacy_manager
+ except Exception as exc: # pragma: no cover
+ raise CommandError(
+ f"Could not import library.models.Library (Neo4j unreachable?): {exc}"
+ ) from exc
+
+ overrides = {
+ "Daedalus": options["daedalus_label"],
+ "Kairos": options["kairos_label"],
+ }
+
+ spelunker_uids = set(
+ IngestJob.objects
+ .filter(source="spelunker")
+ .values_list("library_uid", flat=True)
+ .distinct()
+ )
+
+ candidates = list(Library.nodes.filter(managed_by__isnull=True))
+
+ to_label = []
+ for lib in candidates:
+ label = infer_legacy_manager(lib.workspace_id)
+ if label:
+ label = overrides[label]
+ elif lib.uid in spelunker_uids:
+ label = options["spelunker_label"]
+ if label:
+ to_label.append((lib, label))
+
+ self.stdout.write(f"Libraries with null managed_by: {len(candidates)}")
+ self.stdout.write(
+ self.style.SUCCESS(f"Will label: {len(to_label)} "
+ f"(unmanaged, left null: {len(candidates) - len(to_label)})")
+ )
+
+ for lib, label in to_label:
+ self.stdout.write(f" {lib.uid} {lib.name!r} → {label}")
+
+ if options["dry_run"]:
+ self.stdout.write(self.style.WARNING("--dry-run: nothing written."))
+ return
+
+ for lib, label in to_label:
+ lib.managed_by = label
+ lib.save()
+ logger.info(
+ "backfill_managed_by uid=%s name=%s label=%s",
+ lib.uid, lib.name, label,
+ )
+ self.stdout.write(self.style.SUCCESS("Done."))
diff --git a/mnemosyne/library/models.py b/mnemosyne/library/models.py
index 2594d35..06105bd 100644
--- a/mnemosyne/library/models.py
+++ b/mnemosyne/library/models.py
@@ -51,6 +51,18 @@ class NearbyImageRel(StructuredRel):
# --- Node models ---
+def infer_legacy_manager(workspace_id):
+ """Manager label for pre-``managed_by`` workspace libraries, else None.
+
+ Kairos mail workspaces are recognisable by their deterministic
+ ``kairos-mail-`` id prefix; every other workspace id is a Daedalus
+ workspace UUID.
+ """
+ if not workspace_id:
+ return None
+ return "Kairos" if workspace_id.startswith("kairos-mail-") else "Daedalus"
+
+
class Library(StructuredNode):
"""
Top-level container representing a content library.
@@ -63,6 +75,11 @@ class Library(StructuredNode):
across the whole instance) or *workspace-scoped* (workspace_id set —
visible only to agents inside that Daedalus workspace). Scoping is
enforced structurally by every search query.
+
+ Independently of scoping, a library may be *app-managed*
+ (``managed_by`` set — created through the API by an external app such
+ as Daedalus, Kairos, or Spelunker, which owns its content lifecycle)
+ or unmanaged (created by hand in the web UI).
"""
uid = UniqueIdProperty()
@@ -93,6 +110,12 @@ class Library(StructuredNode):
# this user. Null for global libraries.
owner_username = StringProperty(required=False, index=True)
+ # Name of the API token that created this library ("Daedalus",
+ # "Kairos", "Spelunker", ...). Null for libraries created in the
+ # web UI. Stamped at create time only — token rotation or edits by
+ # another token never change it.
+ managed_by = StringProperty(required=False, index=True)
+
# Content-type configuration
chunking_config = JSONProperty(default={})
embedding_instruction = StringProperty(default="")
@@ -104,6 +127,16 @@ class Library(StructuredNode):
# Relationships
collections = RelationshipTo("Collection", "CONTAINS")
+ @property
+ def managed_by_display(self):
+ """Managing-app label for display; empty string when unmanaged.
+
+ Falls back to inference for workspace libraries created before
+ ``managed_by`` existed, so rendering is identical before and
+ after the backfill command runs.
+ """
+ return self.managed_by or infer_legacy_manager(self.workspace_id) or ""
+
def __str__(self):
return f"{self.name} ({self.library_type})"
diff --git a/mnemosyne/library/templates/library/library_confirm_delete.html b/mnemosyne/library/templates/library/library_confirm_delete.html
index 8474ea9..30a3bbf 100644
--- a/mnemosyne/library/templates/library/library_confirm_delete.html
+++ b/mnemosyne/library/templates/library/library_confirm_delete.html
@@ -12,15 +12,22 @@
Are you sure you want to delete {{ library.name }}? This action cannot be undone.
- {% if library.workspace_id %}
+ {% if library.managed_by_display %}
- This Library is managed by Daedalus
- (workspace {{ library.workspace_id }}).
+ This Library is managed by {{ library.managed_by_display }}{% if library.workspace_id %}
+ (workspace {{ library.workspace_id }}){% endif %}.
+ {% if library.workspace_id %}
Deleting it here removes its embedded content from Mnemosyne, but the
- source files still live in Daedalus — it will be recreated and
- re-embedded on the next Daedalus sync. Use this to clear an
- orphaned Library that is blocking workspace re-registration.
+ source files still live in {{ library.managed_by_display }} — it will
+ be recreated and re-embedded on the next sync. Use
+ this to clear an orphaned Library that is blocking workspace
+ re-registration.
+ {% else %}
+ Deleting it here removes its embedded content from Mnemosyne;
+ {{ library.managed_by_display }} may recreate and re-embed it on its
+ next sync.
+ {% endif %}
- {% if library.workspace_id %}
+ {% if library.managed_by_display %}
- Daedalus workspace
+ {% if library.workspace_id %}title="Workspace {{ library.workspace_id }}"{% endif %}>
+ Managed by {{ library.managed_by_display }}
{% endif %}
@@ -29,18 +29,25 @@
-{% if library.workspace_id %}
+{% if library.managed_by_display %}
-
Managed by Daedalus
+
Managed by {{ library.managed_by_display }}
- This library was created for Daedalus workspace
+ {% if library.workspace_id %}
+ This library was created for workspace
{{ library.workspace_id }}.
- Normally you manage it from Daedalus. Deleting it here removes its
- embedded content from Mnemosyne, but the source files still live in
- Daedalus — it will be recreated and re-embedded on the next sync.
+ Normally you manage it from {{ library.managed_by_display }}.
+ Deleting it here removes its embedded content from Mnemosyne, but
+ the source files still live in {{ library.managed_by_display }} —
+ it will be recreated and re-embedded on the next sync.
Use Delete to clear an orphaned library that is blocking workspace
re-registration.
+ {% else %}
+ Content in this library is pushed by
+ {{ library.managed_by_display }}. Edits made here may be
+ overwritten or re-created on its next sync.
+ {% endif %}