diff --git a/.env.example b/.env.example
index 28e2a81..6ead379 100644
--- a/.env.example
+++ b/.env.example
@@ -52,6 +52,12 @@ PUBLIC_REVOKE_IP_RATE=120/hour
INSTALLATION_PROFILE=custom
# Available profiles: custom, quiet_gallery, shared_lab, active_exhibit
+# Deployment kind for artifact framing. Keep `memory` unless you are actively
+# prototyping another sibling deployment on this same engine.
+ENGINE_DEPLOYMENT=memory
+# Planned modes: memory, question, prompt, repair, witness, oracle
+# If unset, settings default to memory.
+
# Postgres
POSTGRES_DB=memory_engine
POSTGRES_USER=memory_engine
diff --git a/README.md b/README.md
index c66e4a4..853d254 100644
--- a/README.md
+++ b/README.md
@@ -2,6 +2,8 @@
Local-first “room memory” appliance: record a short sound offering, choose consent, receive a revoke code, and let the room replay contributions with **very light decay per access**. Nodes are offline/local-first by design.
+This repo now opens one layer wider: **Memory Engine is still the default deployment**, but it is treated as a memory-first deployment of a broader local-first artifact engine. This is an expansion, not a rebrand. The current runtime, routes, and operator flow stay intact while the config and docs now name future sibling deployments (`question`, `prompt`, `repair`, `witness`, `oracle`) that can be realized mostly through copy, metadata framing, and playback policy.
+
## What you get
- Django + DRF API (Artifacts, Pool playback, Revocation, Node status)
- Postgres for metadata
@@ -12,6 +14,30 @@ Local-first “room memory” appliance: record a short sound offering, choose c
- A participant can now choose a first-pass memory color (`Clear`, `Warm`, `Radio`, `Dream`) during review; the dry WAV stays unchanged in storage and the color choice is stored separately on the artifact for playback
- Those memory-color profiles now come from one shared catalog used by Django, the kiosk review UI, and `/ops/`, so the profile list and first-pass tuning stay aligned across storage, playback, and operator visibility. Audio behavior stays bounded through a small topology dispatch layer rather than arbitrary DSP graphs, so a new profile can often be added by editing the catalog if it reuses an existing topology. `Dream` is seeded from the source audio so preview and later playback stay materially aligned.
+
+## Deployment family (explicit in this pass)
+
+Memory Engine is still the default and production-safe baseline.
+
+This pass makes six deployment kinds explicit and runnable through one shared local-first artifact engine:
+
+- `memory` (default)
+- `question`
+- `prompt`
+- `repair`
+- `witness`
+- `oracle`
+
+Set deployment kind with:
+
+```env
+ENGINE_DEPLOYMENT=memory
+```
+
+If unset, startup defaults to `memory`. If set to an unknown value, startup fails fast during config validation so operators see the mistake immediately.
+
+Practical intent: same routes and steward posture, different intake framing, copy, metadata expectations, and playback weighting.
+
## Quick start
1) Install Docker + Docker Compose.
2) Copy env:
@@ -231,6 +257,14 @@ For common installs, you can also start from a named behavior preset:
INSTALLATION_PROFILE=shared_lab
```
+And you can declare the active deployment kind (default stays `memory`):
+
+```env
+ENGINE_DEPLOYMENT=memory
+```
+
+Planned deployment kinds: `memory`, `question`, `prompt`, `repair`, `witness`, `oracle`.
+
Available profiles:
- `custom`: no bundled behavior overrides
- `quiet_gallery`: quieter pacing and softer overnight posture
@@ -426,3 +460,9 @@ confirms room and ops alignment.
- Policy editor UI (Decay Policy DSL)
- Export bundles (fossils + anonymized stats) to USB
- Federation (fossil-only sync between nodes)
+
+
+## Mission expansion notes
+- `docs/MISSION_EXPANSION.md` — first-pass framing for Memory Engine + sibling deployments on one local-first artifact engine.
+- `docs/DEPLOYMENT_BEHAVIORS.md` — playback/afterlife behavior by deployment.
+- `docs/RESPONSIVENESS.md` — feedback ladder (immediate, near-immediate, ambient afterlife).
diff --git a/api/engine/api_views.py b/api/engine/api_views.py
index 5e1b806..ce794e7 100644
--- a/api/engine/api_views.py
+++ b/api/engine/api_views.py
@@ -3,6 +3,8 @@
from datetime import timedelta
from django.conf import settings
+
+from memory_engine.deployments import deployment_spec
from django.core.cache import cache
from django.db import transaction
from django.http import FileResponse, Http404
@@ -107,6 +109,14 @@ def parse_intish(value, fallback: int) -> int:
return int(fallback)
+def parse_topic_tag(value) -> str:
+ return str(value or "").strip()[:64]
+
+
+def parse_lifecycle_status(value) -> str:
+ return str(value or "").strip().lower()[:32]
+
+
def intake_suspended() -> bool:
state = load_steward_state()
return bool(state.maintenance_mode or state.intake_paused)
@@ -189,6 +199,10 @@ def create_audio_artifact(request):
except ValueError:
return Response({"error": memory_color_validation_error_message()}, status=400)
+ active_deployment = deployment_spec(getattr(settings, "ENGINE_DEPLOYMENT", "memory"))
+ topic_tag = parse_topic_tag(request.data.get("topic_tag"))
+ lifecycle_status = parse_lifecycle_status(request.data.get("lifecycle_status"))
+
upload = request.data.get("file")
if not upload:
return Response({"error": "file required"}, status=400)
@@ -214,6 +228,9 @@ def create_audio_artifact(request):
raw_sha256=hashlib.sha256(data).hexdigest(),
effect_profile=effect_profile,
effect_metadata=memory_color_metadata(effect_profile),
+ deployment_kind=active_deployment.code,
+ topic_tag=topic_tag,
+ lifecycle_status=lifecycle_status,
expires_at=(
timezone.now() + timedelta(days=int(manifest["retention"]["derivative_ttl_days"]))
if consent_mode == "FOSSIL"
@@ -264,6 +281,10 @@ def create_ephemeral_audio(request):
except ValueError:
return Response({"error": memory_color_validation_error_message()}, status=400)
+ active_deployment = deployment_spec(getattr(settings, "ENGINE_DEPLOYMENT", "memory"))
+ topic_tag = parse_topic_tag(request.data.get("topic_tag"))
+ lifecycle_status = parse_lifecycle_status(request.data.get("lifecycle_status"))
+
upload = request.data.get("file")
if not upload:
return Response({"error": "file required"}, status=400)
@@ -289,6 +310,9 @@ def create_ephemeral_audio(request):
raw_sha256=hashlib.sha256(data).hexdigest(),
effect_profile=effect_profile,
effect_metadata=memory_color_metadata(effect_profile),
+ deployment_kind=active_deployment.code,
+ topic_tag=topic_tag,
+ lifecycle_status=lifecycle_status,
expires_at=timezone.now() + timedelta(minutes=5),
duration_ms=validated_upload.duration_ms,
)
@@ -397,7 +421,8 @@ def pool_next(request):
return Response(status=status.HTTP_204_NO_CONTENT)
now = timezone.now()
- playable_count = playable_artifact_queryset(now).count()
+ active_deployment = deployment_spec(getattr(settings, "ENGINE_DEPLOYMENT", "memory"))
+ playable_count = playable_artifact_queryset(now).filter(deployment_kind=active_deployment.code).count()
requested_lane = (request.query_params.get("lane") or "any").strip().lower()
if requested_lane not in {"any", "fresh", "mid", "worn"}:
requested_lane = "any"
@@ -436,6 +461,7 @@ def pool_next(request):
requested_mood,
excluded_ids=excluded_ids,
recent_densities=recent_densities,
+ deployment_code=active_deployment.code,
)
if not artifact:
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -591,8 +617,17 @@ def node_status(request):
})
warnings.extend(pool_warnings(active, lane_counts, mood_counts, playable_count))
+ active_deployment = deployment_spec(getattr(settings, "ENGINE_DEPLOYMENT", "memory"))
+
return Response({
"ok": ok,
+ "deployment": {
+ "code": active_deployment.code,
+ "label": active_deployment.label,
+ "description": active_deployment.short_description,
+ "participant_noun": active_deployment.participant_noun,
+ "playback_policy_key": active_deployment.playback_policy_key,
+ },
"components": components,
"operator_state": operator_state,
"active": active,
diff --git a/api/engine/deployment_policy.py b/api/engine/deployment_policy.py
new file mode 100644
index 0000000..dcadeed
--- /dev/null
+++ b/api/engine/deployment_policy.py
@@ -0,0 +1,48 @@
+"""Deployment-level playback policy hooks.
+
+These hooks intentionally stay lightweight: memory remains canonical and other
+modes apply small weight adjustments instead of replacing pool logic.
+"""
+
+from __future__ import annotations
+
+from memory_engine.deployments import deployment_spec
+
+
+def weight_adjustment(*, deployment_code: str, age_hours: float, absence_hours: float, lifecycle_status: str) -> float:
+ code = deployment_spec(deployment_code).code
+ lifecycle = str(lifecycle_status or "").strip().lower()
+
+ if code == "question":
+ unresolved = lifecycle in {"", "open", "unresolved", "pending"}
+ return 1.22 if unresolved else 0.92
+
+ if code == "prompt":
+ if age_hours <= 36:
+ return 1.12
+ if age_hours >= 360:
+ return 0.88
+ return 1.0
+
+ if code == "repair":
+ if age_hours <= 72:
+ return 1.28
+ if age_hours >= 240:
+ return 0.72
+ return 1.0
+
+ if code == "witness":
+ if age_hours < 2:
+ return 0.86
+ if 8 <= age_hours <= 168:
+ return 1.14
+ return 1.0
+
+ if code == "oracle":
+ if absence_hours >= 120 and age_hours >= 120:
+ return 1.45
+ if age_hours < 12:
+ return 0.62
+ return 0.9
+
+ return 1.0
diff --git a/api/engine/migrations/0010_artifact_deployment_metadata.py b/api/engine/migrations/0010_artifact_deployment_metadata.py
new file mode 100644
index 0000000..4d57413
--- /dev/null
+++ b/api/engine/migrations/0010_artifact_deployment_metadata.py
@@ -0,0 +1,30 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("engine", "0009_artifact_memory_color"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="artifact",
+ name="deployment_kind",
+ field=models.CharField(default="memory", max_length=32),
+ ),
+ migrations.AddField(
+ model_name="artifact",
+ name="topic_tag",
+ field=models.CharField(blank=True, default="", max_length=64),
+ ),
+ migrations.AddField(
+ model_name="artifact",
+ name="lifecycle_status",
+ field=models.CharField(blank=True, default="", max_length=32),
+ ),
+ migrations.AddIndex(
+ model_name="artifact",
+ index=models.Index(fields=["deployment_kind", "status", "expires_at"], name="artifact_deploy_idx"),
+ ),
+ ]
diff --git a/api/engine/models.py b/api/engine/models.py
index 0451953..d6a0c2f 100644
--- a/api/engine/models.py
+++ b/api/engine/models.py
@@ -46,6 +46,11 @@ class Artifact(models.Model):
effect_profile = models.CharField(max_length=32, blank=True, default="")
effect_metadata = models.JSONField(default=dict, blank=True)
+ # Deployment-aware metadata. Defaults preserve Memory Engine behavior.
+ deployment_kind = models.CharField(max_length=32, default="memory")
+ topic_tag = models.CharField(max_length=64, blank=True, default="")
+ lifecycle_status = models.CharField(max_length=32, blank=True, default="")
+
wear = models.FloatField(default=0.0) # 0..1
play_count = models.IntegerField(default=0)
last_access_at = models.DateTimeField(null=True, blank=True)
@@ -69,6 +74,7 @@ class Meta:
fields=["status", "expires_at", "last_access_at", "play_count", "wear", "-created_at"],
name="artifact_pool_cool_idx",
),
+ models.Index(fields=["deployment_kind", "status", "expires_at"], name="artifact_deploy_idx"),
]
class Derivative(models.Model):
diff --git a/api/engine/pool.py b/api/engine/pool.py
index f172f0c..a9e4e78 100644
--- a/api/engine/pool.py
+++ b/api/engine/pool.py
@@ -5,6 +5,9 @@
from django.conf import settings
from django.db.models import Q
+from memory_engine.deployments import deployment_spec
+
+from .deployment_policy import weight_adjustment
from .models import Artifact, Derivative
@@ -97,6 +100,17 @@ def artifact_absence_hours(artifact: Artifact, now) -> float:
return artifact_age_hours(artifact, now)
+def deployment_weight_adjustment(artifact: Artifact, now, deployment_code: str) -> float:
+ """Small deployment-level weighting hook."""
+
+ return weight_adjustment(
+ deployment_code=deployment_code,
+ age_hours=artifact_age_hours(artifact, now),
+ absence_hours=artifact_absence_hours(artifact, now),
+ lifecycle_status=str(getattr(artifact, "lifecycle_status", "") or ""),
+ )
+
+
def artifact_is_featured_return(artifact: Artifact, now) -> bool:
return bool(
artifact_age_hours(artifact, now) >= settings.POOL_FEATURED_RETURN_MIN_AGE_HOURS
@@ -139,6 +153,7 @@ def pool_weight(
cooldown_seconds: int,
preferred_mood: str = "any",
recent_densities: list[str] | None = None,
+ deployment_code: str = "memory",
) -> float:
seconds_since_access = cooldown_seconds * 4
if artifact.last_access_at:
@@ -179,8 +194,12 @@ def pool_weight(
featured_return_factor = float(settings.POOL_FEATURED_RETURN_BOOST) if artifact_is_featured_return(artifact, now) else 1.0
density_factor = density_balance_factor(artifact, now, recent_densities)
+ deployment_factor = deployment_weight_adjustment(artifact, now, deployment_code)
- return max(0.1, cooldown_factor * rarity_factor * wear_factor * age_factor * mood_factor * featured_return_factor * density_factor)
+ return max(
+ 0.1,
+ cooldown_factor * rarity_factor * wear_factor * age_factor * mood_factor * featured_return_factor * density_factor * deployment_factor,
+ )
def artifact_lane(artifact: Artifact, now) -> str:
@@ -233,12 +252,14 @@ def select_pool_artifact(
preferred_mood: str = "any",
excluded_ids: set[int] | None = None,
recent_densities: list[str] | None = None,
+ deployment_code: str = "memory",
):
cooldown_seconds = max(1, int(settings.POOL_PLAY_COOLDOWN_SECONDS))
cooldown_threshold = now - timedelta(seconds=cooldown_seconds)
candidate_limit = max(5, int(settings.POOL_CANDIDATE_LIMIT))
- base_qs = playable_artifact_queryset(now)
+ deployment = deployment_spec(deployment_code)
+ base_qs = playable_artifact_queryset(now).filter(deployment_kind=deployment.code)
preferred_base_qs = base_qs
if excluded_ids:
preferred_base_qs = preferred_base_qs.exclude(id__in=excluded_ids)
@@ -256,6 +277,14 @@ def select_pool_artifact(
candidates = list(cooldown_qs.order_by("play_count", "wear", "-created_at")[:candidate_limit])
if not candidates:
candidates = list(base_qs.order_by("last_access_at", "play_count", "wear", "-created_at")[:candidate_limit])
+ if not candidates and deployment.code != "memory":
+ base_qs = playable_artifact_queryset(now)
+ preferred_base_qs = base_qs.exclude(id__in=excluded_ids) if excluded_ids else base_qs
+ cooldown_qs = preferred_base_qs.filter(Q(last_access_at__isnull=True) | Q(last_access_at__lt=cooldown_threshold))
+ candidates = list(cooldown_qs.order_by("play_count", "wear", "-created_at")[:candidate_limit])
+ if not candidates:
+ candidates = list(preferred_base_qs.order_by("last_access_at", "play_count", "wear", "-created_at")[:candidate_limit])
+
if not candidates:
return None, None
@@ -281,6 +310,7 @@ def select_pool_artifact(
cooldown_seconds,
preferred_mood,
recent_densities=recent_densities,
+ deployment_code=deployment.code,
)
for artifact in candidates
]
diff --git a/api/engine/reporting.py b/api/engine/reporting.py
index c2029a3..98b3c10 100644
--- a/api/engine/reporting.py
+++ b/api/engine/reporting.py
@@ -25,6 +25,7 @@ def artifact_summary_payload(*, now=None) -> dict:
"gathering": 0,
}
memory_color_counts = {profile: 0 for profile in MEMORY_COLOR_PROFILE_ORDER}
+ deployment_counts: dict[str, int] = {}
for artifact in playable_artifacts:
lane_counts[artifact_lane(artifact, current_time)] += 1
@@ -35,6 +36,8 @@ def artifact_summary_payload(*, now=None) -> dict:
) or DEFAULT_MEMORY_COLOR_PROFILE
memory_color_counts.setdefault(effect_profile, 0)
memory_color_counts[effect_profile] += 1
+ deployment_code = str(getattr(artifact, "deployment_kind", "memory") or "memory").strip().lower() or "memory"
+ deployment_counts[deployment_code] = deployment_counts.get(deployment_code, 0) + 1
return {
"generated_at": current_time,
@@ -48,5 +51,6 @@ def artifact_summary_payload(*, now=None) -> dict:
"counts": memory_color_counts,
"catalog": memory_color_catalog_payload(),
},
+ "deployments": deployment_counts,
"retention": retention_summary(now=current_time),
}
diff --git a/api/engine/serializers.py b/api/engine/serializers.py
index ca28de5..e274a04 100644
--- a/api/engine/serializers.py
+++ b/api/engine/serializers.py
@@ -6,7 +6,8 @@ class Meta:
model = Artifact
fields = [
"id","kind","status","created_at","expires_at",
- "wear","play_count","duration_ms","effect_profile","effect_metadata"
+ "wear","play_count","duration_ms","effect_profile","effect_metadata",
+ "deployment_kind","topic_tag","lifecycle_status"
]
class DerivativeSerializer(serializers.ModelSerializer):
diff --git a/api/engine/static/engine/kiosk-copy.js b/api/engine/static/engine/kiosk-copy.js
index dea395d..4817bf2 100644
--- a/api/engine/static/engine/kiosk-copy.js
+++ b/api/engine/static/engine/kiosk-copy.js
@@ -426,6 +426,231 @@
},
};
+
+
+ const DEPLOYMENT_OVERRIDES = {
+ memory: {
+ en: {
+ deploymentLabel: "Memory Engine",
+ },
+ },
+ question: {
+ en: {
+ deploymentLabel: "Question Engine",
+ documentTitle: "Question Engine - Recording Station",
+ heroTitle: "Room Questions",
+ heroSub: "Offer a question to this room. Record once, review once, and let it return until it settles.",
+ stageIdleTitle: "When you are ready, wake the question microphone.",
+ stageRecordingTitle: "Ask what needs asking.",
+ stageReviewTitle: "Listen back, then choose what follows this question.",
+ btnChooseMemoryMode: "Choose a question mode",
+ btnStartAnother: "Start another question",
+ shortcutModeChoice: "Press 1, 2, or 3 to choose a question mode",
+ processingNoteQuiet: "Take captured. The input stayed very quiet, so please keep or retake it before choosing a question mode.",
+ quietTakeKept: "Quiet question kept. Choose how this room should hold it.",
+ modes: {
+ ROOM: {
+ name: "Room Question",
+ submitLabel: "Keep in room questions",
+ reviewCopy: "Stored on this device and resurfaced as an unresolved room question.",
+ completeTitle: "Question kept in this room.",
+ completeStatus: "Question saved locally.",
+ optionCopy: "Store locally and let this question return in the room.",
+ },
+ FOSSIL: {
+ name: "Question Trace",
+ submitLabel: "Save as question trace",
+ reviewCopy: "Raw audio fades sooner while a lighter trace can remain for longer local return.",
+ completeTitle: "Question saved as a local trace.",
+ completeStatus: "Question trace saved locally.",
+ optionCopy: "Raw fades sooner; a local trace may remain.",
+ },
+ NOSAVE: {
+ name: "Ask Once",
+ submitLabel: "Play once, then clear",
+ reviewCopy: "Play this question one time immediately, then remove it.",
+ completeTitle: "Question played once and cleared.",
+ completeStatus: "Question cleared after playback.",
+ optionCopy: "Play once immediately, then clear.",
+ },
+ },
+ },
+ },
+ prompt: {
+ en: {
+ deploymentLabel: "Prompt Engine",
+ documentTitle: "Prompt Engine - Recording Station",
+ heroTitle: "Room Prompts",
+ heroSub: "Offer a prompt response to this room. Keep it crisp, catalytic, and ready to spark another pass.",
+ stageIdleTitle: "When you are ready, wake the prompt microphone.",
+ stageRecordingTitle: "Offer your prompt response.",
+ stageReviewTitle: "Listen back, then choose how this prompt response should circulate.",
+ btnChooseMemoryMode: "Choose a prompt mode",
+ btnStartAnother: "Start another prompt response",
+ shortcutModeChoice: "Press 1, 2, or 3 to choose a prompt mode",
+ processingNoteQuiet: "Take captured. The input stayed very quiet, so please keep or retake it before choosing a prompt mode.",
+ quietTakeKept: "Quiet prompt kept. Choose how this room should replay it.",
+ modes: {
+ ROOM: {
+ name: "Room Prompt",
+ submitLabel: "Keep in prompt cycle",
+ reviewCopy: "Stored locally and resurfaced to keep the room's prompt cycle alive.",
+ completeTitle: "Prompt response saved in-room.",
+ completeStatus: "Prompt response saved locally.",
+ optionCopy: "Store locally and replay as part of the room's prompt cycle.",
+ },
+ FOSSIL: {
+ name: "Prompt Residue",
+ submitLabel: "Save prompt residue",
+ reviewCopy: "Raw audio expires sooner while a compact local residue can remain.",
+ completeTitle: "Prompt response saved as residue.",
+ completeStatus: "Prompt residue saved locally.",
+ optionCopy: "Raw fades sooner; residue may remain for later local use.",
+ },
+ NOSAVE: {
+ name: "Flash Prompt",
+ submitLabel: "Play once, then drop",
+ reviewCopy: "Play this response one time right away, then drop it.",
+ completeTitle: "Prompt response played once.",
+ completeStatus: "Prompt response dropped.",
+ optionCopy: "Play once immediately, then drop.",
+ },
+ },
+ },
+ },
+ repair: {
+ en: {
+ deploymentLabel: "Repair Engine",
+ documentTitle: "Repair Engine - Recording Station",
+ heroTitle: "Room Repair",
+ heroSub: "Record a practical repair note for this room: what is broken, what helps, what should happen next.",
+ stageIdleTitle: "When you are ready, wake the repair microphone.",
+ stageRecordingTitle: "Record what needs repair.",
+ stageReviewTitle: "Listen back, then choose how this repair note should be handled.",
+ btnChooseMemoryMode: "Choose a repair mode",
+ btnStartAnother: "Start another repair note",
+ shortcutModeChoice: "Press 1, 2, or 3 to choose a repair mode",
+ processingNoteQuiet: "Take captured. The input stayed very quiet, so please keep or retake it before choosing a repair mode.",
+ quietTakeKept: "Quiet repair note kept. Choose how this room should hold it.",
+ modes: {
+ ROOM: {
+ name: "Room Repair",
+ submitLabel: "Keep in repair queue",
+ reviewCopy: "Stored locally and favored for practical resurfacing in this room.",
+ completeTitle: "Repair note saved locally.",
+ completeStatus: "Repair note queued in-room.",
+ optionCopy: "Store locally and keep it available for practical resurfacing.",
+ },
+ FOSSIL: {
+ name: "Repair Trace",
+ submitLabel: "Save repair trace",
+ reviewCopy: "Raw fades sooner while a lighter trace can remain for local reference.",
+ completeTitle: "Repair trace saved.",
+ completeStatus: "Repair trace saved locally.",
+ optionCopy: "Raw fades sooner; a local trace can remain.",
+ },
+ NOSAVE: {
+ name: "One-off Fix",
+ submitLabel: "Play once, then clear",
+ reviewCopy: "Play this repair note one time now, then remove it.",
+ completeTitle: "Repair note played once.",
+ completeStatus: "Repair note cleared.",
+ optionCopy: "Play once immediately, then clear.",
+ },
+ },
+ },
+ },
+ witness: {
+ en: {
+ deploymentLabel: "Witness Engine",
+ documentTitle: "Witness Engine - Recording Station",
+ heroTitle: "Room Witness",
+ heroSub: "Offer a careful witness note for this room. Speak clearly and keep context intact.",
+ stageIdleTitle: "When you are ready, wake the witness microphone.",
+ stageRecordingTitle: "Record what you witnessed.",
+ stageReviewTitle: "Listen back, then choose how this witness note should return.",
+ btnChooseMemoryMode: "Choose a witness mode",
+ btnStartAnother: "Start another witness note",
+ shortcutModeChoice: "Press 1, 2, or 3 to choose a witness mode",
+ processingNoteQuiet: "Take captured. The input stayed very quiet, so please keep or retake it before choosing a witness mode.",
+ quietTakeKept: "Quiet witness note kept. Choose how this room should hold it.",
+ modes: {
+ ROOM: {
+ name: "Room Witness",
+ submitLabel: "Keep in witness archive",
+ reviewCopy: "Stored locally for careful, contextual return in the room.",
+ completeTitle: "Witness note saved in this room.",
+ completeStatus: "Witness note saved locally.",
+ optionCopy: "Store locally and return with documentary pacing.",
+ },
+ FOSSIL: {
+ name: "Witness Trace",
+ submitLabel: "Save witness trace",
+ reviewCopy: "Raw fades sooner while a lighter local trace remains for context.",
+ completeTitle: "Witness trace saved locally.",
+ completeStatus: "Witness trace retained.",
+ optionCopy: "Raw fades sooner; local trace can remain.",
+ },
+ NOSAVE: {
+ name: "Witness Once",
+ submitLabel: "Play once, then clear",
+ reviewCopy: "Play this witness note once immediately, then remove it.",
+ completeTitle: "Witness note played once.",
+ completeStatus: "Witness note cleared.",
+ optionCopy: "Play once immediately, then clear.",
+ },
+ },
+ },
+ },
+ oracle: {
+ en: {
+ deploymentLabel: "Oracle Engine",
+ documentTitle: "Oracle Engine - Recording Station",
+ heroTitle: "Room Oracle",
+ heroSub: "Offer a sparse oracle fragment to this room. Keep it concise, intentional, and resonant.",
+ stageIdleTitle: "When you are ready, wake the oracle microphone.",
+ stageRecordingTitle: "Offer the fragment.",
+ stageReviewTitle: "Listen back, then choose how this oracle fragment should be held.",
+ btnChooseMemoryMode: "Choose an oracle mode",
+ btnStartAnother: "Start another oracle fragment",
+ shortcutModeChoice: "Press 1, 2, or 3 to choose an oracle mode",
+ processingNoteQuiet: "Take captured. The input stayed very quiet, so please keep or retake it before choosing an oracle mode.",
+ quietTakeKept: "Quiet oracle fragment kept. Choose its afterlife.",
+ modes: {
+ ROOM: {
+ name: "Oracle Archive",
+ submitLabel: "Keep for rare return",
+ reviewCopy: "Stored locally and resurfaced rarely, with ceremonial timing.",
+ completeTitle: "Oracle fragment saved.",
+ completeStatus: "Oracle fragment stored locally.",
+ optionCopy: "Store locally for sparse, rare room return.",
+ },
+ FOSSIL: {
+ name: "Oracle Trace",
+ submitLabel: "Save oracle trace",
+ reviewCopy: "Raw fades sooner; a compact trace can remain for rare local resurfacing.",
+ completeTitle: "Oracle trace saved.",
+ completeStatus: "Oracle trace retained.",
+ optionCopy: "Raw fades sooner; local trace can remain.",
+ },
+ NOSAVE: {
+ name: "Single Omen",
+ submitLabel: "Play once, then vanish",
+ reviewCopy: "Play this fragment one time right away, then remove it.",
+ completeTitle: "Oracle fragment played once.",
+ completeStatus: "Oracle fragment vanished.",
+ optionCopy: "Play once immediately, then vanish.",
+ },
+ },
+ },
+ },
+ };
+
+ function normalizeDeployment(value) {
+ const code = String(value || "").trim().toLowerCase();
+ return Object.prototype.hasOwnProperty.call(DEPLOYMENT_OVERRIDES, code) ? code : "memory";
+ }
+
function normalizeLanguageCode(value) {
const code = String(value || "").trim().toLowerCase();
return Object.prototype.hasOwnProperty.call(PACKS, code) ? code : "";
@@ -439,6 +664,15 @@
return PACKS[resolveLanguageCode(code, "en")] || PACKS.en;
}
+ function getDeploymentPack(code, deployment) {
+ const languageCode = resolveLanguageCode(code, "en");
+ const base = getPack(languageCode);
+ const deploymentCode = normalizeDeployment(deployment);
+ const deploymentPack = DEPLOYMENT_OVERRIDES[deploymentCode] || {};
+ const override = deploymentPack[languageCode] || deploymentPack.en || {};
+ return { ...base, ...override };
+ }
+
function formatMessage(template, values = {}) {
return String(template || "").replace(/\{(\w+)\}/g, (_, key) => {
if (Object.prototype.hasOwnProperty.call(values, key)) {
@@ -452,6 +686,8 @@
PACKS,
formatMessage,
getPack,
+ getDeploymentPack,
+ normalizeDeployment,
normalizeLanguageCode,
resolveLanguageCode,
};
diff --git a/api/engine/static/engine/kiosk.js b/api/engine/static/engine/kiosk.js
index 5d951db..67779b2 100644
--- a/api/engine/static/engine/kiosk.js
+++ b/api/engine/static/engine/kiosk.js
@@ -161,6 +161,7 @@ function readKioskConfig() {
const kioskConfig = readKioskConfig();
const DEFAULT_LANGUAGE_CODE = String(kioskConfig.kioskLanguageCode || "en");
const DEFAULT_MAX_RECORDING_SECONDS = Number(kioskConfig.kioskMaxRecordingSeconds || 120);
+const ENGINE_DEPLOYMENT = String(kioskConfig.engineDeployment || "memory");
function buildMemoryChoiceButton(profile) {
const choice = document.createElement("button");
@@ -209,7 +210,7 @@ function currentLanguageCode() {
}
function currentCopy() {
- return kioskCopyApi.getPack(currentLanguageCode());
+ return kioskCopyApi.getDeploymentPack(currentLanguageCode(), ENGINE_DEPLOYMENT);
}
function formatCopy(template, values = {}) {
diff --git a/api/engine/static/engine/operator-dashboard.js b/api/engine/static/engine/operator-dashboard.js
index 247eb51..c803544 100644
--- a/api/engine/static/engine/operator-dashboard.js
+++ b/api/engine/static/engine/operator-dashboard.js
@@ -214,8 +214,15 @@
.slice(0, 3)
.map(([mood, count]) => `${mood} ${count}`)
.join(" · ");
+ const deployment = payload.deployment || {};
return [
+ {
+ tagName: "article",
+ className: "component-card ready",
+ title: "Active deployment",
+ detail: `${deployment.label || "Memory Engine"} (${deployment.code || "memory"}) · ${deployment.description || "room-memory default posture"}`,
+ },
{
tagName: "article",
className: "component-card ready",
diff --git a/api/engine/templates/engine/operator_dashboard.html b/api/engine/templates/engine/operator_dashboard.html
index 36686b4..ac7b8f0 100644
--- a/api/engine/templates/engine/operator_dashboard.html
+++ b/api/engine/templates/engine/operator_dashboard.html
@@ -12,6 +12,7 @@
Operator dashboard
Room Memory Status
This page shows whether the node is healthy enough to operate, what the room currently holds, and when the last refresh happened.
+ Active deployment: {{ engine_deployment.label }} ({{ engine_deployment.code }}). {{ engine_deployment.ops_note }} Policy key: {{ engine_deployment.playback_policy_key }}.
diff --git a/api/engine/tests/base.py b/api/engine/tests/base.py
index 4274e7f..2c05f11 100644
--- a/api/engine/tests/base.py
+++ b/api/engine/tests/base.py
@@ -46,6 +46,7 @@ def make_active_artifact(self, *, consent=None, **overrides) -> Artifact:
def default_runtime_config(**overrides):
payload = {
"INSTALLATION_PROFILE": "custom",
+ "ENGINE_DEPLOYMENT": "memory",
"ALLOWED_HOSTS": ["localhost"],
"CSRF_TRUSTED_ORIGINS": ["http://localhost"],
"MINIO_ENDPOINT": "http://minio:9000",
diff --git a/api/engine/tests/test_artifacts.py b/api/engine/tests/test_artifacts.py
index 96241a1..e6399cb 100644
--- a/api/engine/tests/test_artifacts.py
+++ b/api/engine/tests/test_artifacts.py
@@ -40,6 +40,28 @@ def test_room_save_creates_active_artifact_and_revocation_token(self, put_bytes_
self.assertEqual(len(response.json()["revocation_token"]), 10)
put_bytes_mock.assert_called_once()
+ @override_settings(ENGINE_DEPLOYMENT="question")
+ @patch("engine.api_views.put_bytes")
+ def test_room_save_uses_active_engine_deployment_for_artifact_metadata(self, put_bytes_mock):
+ upload = SimpleUploadedFile("audio.wav", make_test_wav_bytes(seconds=2.0), content_type="audio/wav")
+
+ response = self.client.post(
+ "/api/v1/artifacts/audio",
+ {
+ "file": upload,
+ "consent_mode": "ROOM",
+ "topic_tag": "entry_gate",
+ "lifecycle_status": "open",
+ },
+ )
+
+ self.assertEqual(response.status_code, 201)
+ artifact = Artifact.objects.get()
+ self.assertEqual(artifact.deployment_kind, "question")
+ self.assertEqual(artifact.topic_tag, "entry_gate")
+ self.assertEqual(artifact.lifecycle_status, "open")
+ put_bytes_mock.assert_called_once()
+
@patch("engine.api_views.put_bytes")
def test_room_save_stores_memory_color_profile_separately_from_raw_audio(self, put_bytes_mock):
upload = SimpleUploadedFile("audio.wav", make_test_wav_bytes(seconds=2.4), content_type="audio/wav")
diff --git a/api/engine/tests/test_config.py b/api/engine/tests/test_config.py
index 08fdac4..948ac3b 100644
--- a/api/engine/tests/test_config.py
+++ b/api/engine/tests/test_config.py
@@ -1,5 +1,7 @@
+from pathlib import Path
from django.core.exceptions import ImproperlyConfigured
+from memory_engine.deployments import DEFAULT_ENGINE_DEPLOYMENT, available_engine_deployments, deployment_catalog_payload
from memory_engine.installation_profiles import installation_profile_default
from .base import EngineTestCase, default_runtime_config, validate_runtime_settings
@@ -9,6 +11,10 @@ class RuntimeConfigValidationTests(EngineTestCase):
def test_runtime_config_validation_accepts_default_test_like_values(self):
validate_runtime_settings(default_runtime_config())
+ def test_runtime_config_validation_accepts_all_supported_engine_deployments(self):
+ for deployment in available_engine_deployments():
+ validate_runtime_settings(default_runtime_config(ENGINE_DEPLOYMENT=deployment))
+
def test_runtime_config_validation_rejects_inverted_thresholds(self):
config = default_runtime_config(
POOL_WORN_MIN_AGE_HOURS=6.0,
@@ -65,6 +71,14 @@ def test_runtime_config_validation_rejects_unknown_installation_profile(self):
self.assertIn("INSTALLATION_PROFILE", str(ctx.exception))
+ def test_runtime_config_validation_rejects_unknown_engine_deployment(self):
+ config = default_runtime_config(ENGINE_DEPLOYMENT="mystery")
+
+ with self.assertRaises(ImproperlyConfigured) as ctx:
+ validate_runtime_settings(config)
+
+ self.assertIn("ENGINE_DEPLOYMENT", str(ctx.exception))
+
def test_runtime_config_validation_rejects_invalid_operator_allowlist_entry(self):
config = default_runtime_config(OPS_ALLOWED_NETWORKS=["not-a-cidr"])
@@ -116,6 +130,18 @@ def test_runtime_config_validation_rejects_unknown_operator_lockout_scope(self):
self.assertIn("OPS_LOGIN_LOCKOUT_SCOPE", str(ctx.exception))
+
+ def test_engine_deployment_catalog_includes_planned_modes(self):
+ self.assertEqual(DEFAULT_ENGINE_DEPLOYMENT, "memory")
+ self.assertEqual(
+ available_engine_deployments(),
+ ("memory", "question", "prompt", "repair", "witness", "oracle"),
+ )
+ catalog = deployment_catalog_payload()
+ self.assertEqual(len(catalog), 6)
+ self.assertTrue(all(item.get("copyCatalogKey") for item in catalog))
+ self.assertTrue(all(item.get("playbackPolicyKey") for item in catalog))
+
def test_installation_profile_defaults_return_expected_values(self):
self.assertEqual(
installation_profile_default("shared_lab", "KIOSK_DEFAULT_MAX_RECORDING_SECONDS", 120),
@@ -131,3 +157,9 @@ def test_installation_profile_defaults_fall_back_to_explicit_default_for_custom_
installation_profile_default("custom", "ROOM_TONE_PROFILE", "soft_air"),
"soft_air",
)
+
+ def test_env_example_mentions_engine_deployment_and_supported_modes(self):
+ env_example = Path(__file__).resolve().parents[3] / ".env.example"
+ payload = env_example.read_text(encoding="utf-8")
+ self.assertIn("ENGINE_DEPLOYMENT=memory", payload)
+ self.assertIn("memory, question, prompt, repair, witness, oracle", payload)
diff --git a/api/engine/tests/test_ops.py b/api/engine/tests/test_ops.py
index 6a9ca90..3594026 100644
--- a/api/engine/tests/test_ops.py
+++ b/api/engine/tests/test_ops.py
@@ -265,6 +265,15 @@ def test_maintenance_mode_forces_pool_next_to_hold(self):
self.assertEqual(response.status_code, 204)
+ def test_node_status_reports_active_engine_deployment(self):
+ self.login_operator()
+
+ response = self.client.get("/api/v1/node/status")
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.json()["deployment"]["code"], "memory")
+ self.assertEqual(response.json()["deployment"]["playback_policy_key"], "memory_default")
+
@patch("engine.api_views.health_component_status")
def test_node_status_reports_empty_pool_warning(self, health_mock):
health_mock.return_value = (
diff --git a/api/engine/tests/test_pool.py b/api/engine/tests/test_pool.py
index 3df4bb5..f318227 100644
--- a/api/engine/tests/test_pool.py
+++ b/api/engine/tests/test_pool.py
@@ -168,3 +168,47 @@ def test_pool_weight_boosts_featured_return_after_long_absence(self):
old_weight = pool_weight(merely_old, now, cooldown_seconds=90)
self.assertGreater(return_weight, old_weight)
+
+ def test_pool_weight_question_prefers_unresolved_items(self):
+ now = timezone.now()
+ consent = self.make_consent("ROOM")
+ unresolved = self.make_active_artifact(
+ consent=consent,
+ raw_uri="raw/open.wav",
+ lifecycle_status="open",
+ created_at=now - timedelta(hours=20),
+ last_access_at=now - timedelta(hours=5),
+ )
+ answered = self.make_active_artifact(
+ consent=consent,
+ raw_uri="raw/answered.wav",
+ lifecycle_status="answered",
+ created_at=now - timedelta(hours=20),
+ last_access_at=now - timedelta(hours=5),
+ )
+
+ unresolved_weight = pool_weight(unresolved, now, cooldown_seconds=90, deployment_code="question")
+ answered_weight = pool_weight(answered, now, cooldown_seconds=90, deployment_code="question")
+
+ self.assertGreater(unresolved_weight, answered_weight)
+
+ def test_pool_weight_oracle_penalizes_brand_new_material(self):
+ now = timezone.now()
+ consent = self.make_consent("ROOM")
+ new_item = self.make_active_artifact(
+ consent=consent,
+ raw_uri="raw/new-oracle.wav",
+ created_at=now - timedelta(hours=1),
+ last_access_at=now - timedelta(hours=1),
+ )
+ old_absent = self.make_active_artifact(
+ consent=consent,
+ raw_uri="raw/old-oracle.wav",
+ created_at=now - timedelta(days=10),
+ last_access_at=now - timedelta(days=7),
+ )
+
+ new_weight = pool_weight(new_item, now, cooldown_seconds=90, deployment_code="oracle")
+ old_weight = pool_weight(old_absent, now, cooldown_seconds=90, deployment_code="oracle")
+
+ self.assertGreater(old_weight, new_weight)
diff --git a/api/engine/views.py b/api/engine/views.py
index 2ccaf69..94bd456 100644
--- a/api/engine/views.py
+++ b/api/engine/views.py
@@ -2,6 +2,8 @@
from django.shortcuts import redirect
from django.shortcuts import render
+from memory_engine.deployments import deployment_catalog_payload, deployment_spec
+
from .media_access import PURPOSE_SURFACE_FOSSILS, build_surface_token, surface_fossils_url
from .memory_color import memory_color_catalog_payload
from .operator_auth import (
@@ -21,6 +23,7 @@
def room_surface_config():
+ active_deployment = deployment_spec(getattr(settings, "ENGINE_DEPLOYMENT", "memory"))
schedule = room_schedule_snapshot(
intensity_profile=settings.ROOM_INTENSITY_PROFILE,
movement_preset=settings.ROOM_MOVEMENT_PRESET,
@@ -36,6 +39,11 @@ def room_surface_config():
tone_source_url=settings.ROOM_TONE_SOURCE_URL,
)
return {
+ "engineDeployment": active_deployment.code,
+ "engineDeploymentLabel": active_deployment.label,
+ "engineDeploymentParticipantNoun": active_deployment.participant_noun,
+ "engineDeploymentPlaybackPolicyKey": active_deployment.playback_policy_key,
+ "engineDeploymentCatalog": deployment_catalog_payload(),
"kioskLanguageCode": str(getattr(settings, "KIOSK_DEFAULT_LANGUAGE_CODE", "en")),
"kioskMaxRecordingSeconds": int(getattr(settings, "KIOSK_DEFAULT_MAX_RECORDING_SECONDS", 120)),
"roomIntensityProfile": schedule["intensityProfile"],
@@ -127,8 +135,16 @@ def operator_dashboard_view(request):
"allowlist_enabled": allowlist_enabled,
}, status=503 if not operator_secret_configured() else 200)
+ active_deployment = deployment_spec(getattr(settings, "ENGINE_DEPLOYMENT", "memory"))
return render(request, "engine/operator_dashboard.html", {
"operator_state": steward_state_payload(),
+ "engine_deployment": {
+ "code": active_deployment.code,
+ "label": active_deployment.label,
+ "description": active_deployment.short_description,
+ "ops_note": active_deployment.ops_note,
+ "playback_policy_key": active_deployment.playback_policy_key,
+ },
})
diff --git a/api/memory_engine/config_validation.py b/api/memory_engine/config_validation.py
index 2e8ab1a..78c6386 100644
--- a/api/memory_engine/config_validation.py
+++ b/api/memory_engine/config_validation.py
@@ -4,6 +4,7 @@
from django.core.exceptions import ImproperlyConfigured
+from .deployments import available_engine_deployments
from .installation_profiles import available_installation_profiles
@@ -58,6 +59,7 @@ def validate_runtime_settings(settings_obj) -> None:
room_tone_source_url = str(getattr(settings_obj, "ROOM_TONE_SOURCE_URL", "") or "").strip()
kiosk_default_language_code = str(getattr(settings_obj, "KIOSK_DEFAULT_LANGUAGE_CODE", "en") or "").strip().lower()
installation_profile = str(getattr(settings_obj, "INSTALLATION_PROFILE", "custom") or "").strip().lower()
+ engine_deployment = str(getattr(settings_obj, "ENGINE_DEPLOYMENT", "memory") or "").strip().lower()
ops_session_binding_mode = str(getattr(settings_obj, "OPS_SESSION_BINDING_MODE", "user_agent") or "").strip().lower()
ops_login_lockout_scope = str(getattr(settings_obj, "OPS_LOGIN_LOCKOUT_SCOPE", "ip_user_agent") or "").strip().lower()
if room_tone_source_mode not in {"synthetic", "site_ambience"}:
@@ -128,6 +130,9 @@ def validate_runtime_settings(settings_obj) -> None:
if installation_profile not in set(available_installation_profiles()):
joined_profiles = ", ".join(available_installation_profiles())
errors.append(f"INSTALLATION_PROFILE must be one of: {joined_profiles}.")
+ if engine_deployment not in set(available_engine_deployments()):
+ joined_deployments = ", ".join(available_engine_deployments())
+ errors.append(f"ENGINE_DEPLOYMENT must be one of: {joined_deployments}.")
if ops_session_binding_mode not in {"strict", "user_agent", "none"}:
errors.append("OPS_SESSION_BINDING_MODE must be 'strict', 'user_agent', or 'none'.")
if ops_login_lockout_scope not in {"ip", "ip_user_agent"}:
diff --git a/api/memory_engine/deployments.py b/api/memory_engine/deployments.py
new file mode 100644
index 0000000..6e492e4
--- /dev/null
+++ b/api/memory_engine/deployments.py
@@ -0,0 +1,119 @@
+"""Deployment catalog for artifact/offering engine variants.
+
+Memory Engine remains the canonical default. This module intentionally stays
+small and explicit so deployment differences remain inspectable.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+DEFAULT_ENGINE_DEPLOYMENT = "memory"
+
+
+@dataclass(frozen=True)
+class DeploymentSpec:
+ code: str
+ label: str
+ short_description: str
+ participant_noun: str
+ framing_noun: str
+ copy_catalog_key: str
+ playback_policy_key: str
+ ops_note: str
+
+
+DEPLOYMENT_SPECS: tuple[DeploymentSpec, ...] = (
+ DeploymentSpec(
+ code="memory",
+ label="Memory Engine",
+ short_description="Room-memory default with weathering and temporal depth.",
+ participant_noun="memory",
+ framing_noun="offering",
+ copy_catalog_key="memory",
+ playback_policy_key="memory_default",
+ ops_note="Reflective room-memory posture with patina and gradual return.",
+ ),
+ DeploymentSpec(
+ code="question",
+ label="Question Engine",
+ short_description="Inquiry-forward capture where unresolved returns can recur.",
+ participant_noun="question",
+ framing_noun="inquiry offering",
+ copy_catalog_key="question",
+ playback_policy_key="question_recurrence",
+ ops_note="Favors unresolved recurrence and thematic resurfacing.",
+ ),
+ DeploymentSpec(
+ code="prompt",
+ label="Prompt Engine",
+ short_description="Prompt-led intake where authored cues catalyze variation.",
+ participant_noun="prompt response",
+ framing_noun="prompt offering",
+ copy_catalog_key="prompt",
+ playback_policy_key="prompt_catalytic",
+ ops_note="Keeps variety high and returns catalyst-like cues quickly.",
+ ),
+ DeploymentSpec(
+ code="repair",
+ label="Repair Engine",
+ short_description="Practical capture tuned for useful resurfacing and recency.",
+ participant_noun="repair note",
+ framing_noun="repair offering",
+ copy_catalog_key="repair",
+ playback_policy_key="repair_recency",
+ ops_note="Biases toward recent, useful, less-repetitive return patterns.",
+ ),
+ DeploymentSpec(
+ code="witness",
+ label="Witness Engine",
+ short_description="Observation-oriented capture with documentary pacing.",
+ participant_noun="witness note",
+ framing_noun="witness offering",
+ copy_catalog_key="witness",
+ playback_policy_key="witness_documentary",
+ ops_note="Prefers careful contextual pacing and documentary clarity.",
+ ),
+ DeploymentSpec(
+ code="oracle",
+ label="Oracle Engine",
+ short_description="Ceremonial sparse resurfacing with rarity-forward timing.",
+ participant_noun="oracle fragment",
+ framing_noun="oracle offering",
+ copy_catalog_key="oracle",
+ playback_policy_key="oracle_rare",
+ ops_note="Prioritizes rarity, longer gaps, and meaningful reappearance.",
+ ),
+)
+
+DEPLOYMENT_SPEC_BY_CODE = {spec.code: spec for spec in DEPLOYMENT_SPECS}
+
+
+def available_engine_deployments() -> tuple[str, ...]:
+ return tuple(spec.code for spec in DEPLOYMENT_SPECS)
+
+
+def normalize_engine_deployment_name(value: str | None) -> str:
+ code = str(value or "").strip().lower()
+ return code or DEFAULT_ENGINE_DEPLOYMENT
+
+
+def deployment_spec(code: str | None) -> DeploymentSpec:
+ normalized = normalize_engine_deployment_name(code)
+ return DEPLOYMENT_SPEC_BY_CODE.get(normalized, DEPLOYMENT_SPEC_BY_CODE[DEFAULT_ENGINE_DEPLOYMENT])
+
+
+def deployment_catalog_payload() -> list[dict[str, str]]:
+ return [
+ {
+ "code": spec.code,
+ "label": spec.label,
+ "description": spec.short_description,
+ "participantNoun": spec.participant_noun,
+ "framingNoun": spec.framing_noun,
+ "copyCatalogKey": spec.copy_catalog_key,
+ "playbackPolicyKey": spec.playback_policy_key,
+ "opsNote": spec.ops_note,
+ }
+ for spec in DEPLOYMENT_SPECS
+ ]
diff --git a/api/memory_engine/settings.py b/api/memory_engine/settings.py
index 553c8a1..119aee3 100644
--- a/api/memory_engine/settings.py
+++ b/api/memory_engine/settings.py
@@ -5,6 +5,7 @@
from django.core.exceptions import ImproperlyConfigured
from .config_validation import validate_runtime_settings
+from .deployments import DEFAULT_ENGINE_DEPLOYMENT, normalize_engine_deployment_name
from .installation_profiles import (
DEFAULT_INSTALLATION_PROFILE,
installation_profile_default,
@@ -36,6 +37,10 @@ def env_float(name: str, default: float) -> float:
os.getenv("INSTALLATION_PROFILE", DEFAULT_INSTALLATION_PROFILE),
)
+ENGINE_DEPLOYMENT = normalize_engine_deployment_name(
+ os.getenv("ENGINE_DEPLOYMENT", DEFAULT_ENGINE_DEPLOYMENT),
+)
+
def profile_default(name: str, fallback):
return installation_profile_default(INSTALLATION_PROFILE, name, fallback)
diff --git a/docs/DEPLOYMENT_BEHAVIORS.md b/docs/DEPLOYMENT_BEHAVIORS.md
new file mode 100644
index 0000000..880b3bc
--- /dev/null
+++ b/docs/DEPLOYMENT_BEHAVIORS.md
@@ -0,0 +1,38 @@
+# Deployment Behaviors and Afterlife Posture
+
+This repo runs one local-first artifact/offering engine with explicit deployment kinds.
+Memory Engine stays canonical. Other modes branch through copy + metadata + policy seams.
+
+## Deployment quick map
+
+| Deployment | Participant ask | Useful metadata | Room resurfacing posture | Afterlife stance | Responsiveness emphasis |
+|---|---|---|---|---|---|
+| `memory` | Offer a room memory | memory color, tone, duration | weathering, patina, temporal depth | layered local residue and worn return | reflective but immediate |
+| `question` | Ask what is unresolved | topic tag, lifecycle (`open`/`answered`) | recurrence + unresolved return | unresolved items keep coming back | clear acknowledgement of inquiry |
+| `prompt` | Offer a prompt response | topic tag, session cue | catalytic rotation + variety | keeps the cycle moving, avoids stagnation | fast iteration loops |
+| `repair` | Record practical repair notes | topic tag, lifecycle, recency | recency/usefulness bias | practical resurfacing near active workflows | utility-first feedback |
+| `witness` | Record a careful witness note | topic tag, context marker | contextual pacing, documentary clarity | less churn, more trace continuity | calm but explicit confirmation |
+| `oracle` | Offer a sparse oracle fragment | lifecycle + rarity framing | rare, ceremonial timing | sparse but meaningful recurrence | immediate acknowledgment, delayed return |
+
+## Already real in code
+
+- Deployment catalog with copy/policy references: `api/memory_engine/deployments.py`
+- Deployment-aware kiosk copy selection: `api/engine/static/engine/kiosk-copy.js`
+- Deployment-aware playback weight hook: `api/engine/deployment_policy.py`
+- Deployment metadata on artifacts: `deployment_kind`, `topic_tag`, `lifecycle_status`
+
+## Playback hook posture (small and intentional)
+
+Current deployment policies are weighting adjustments, not a second engine:
+
+- `memory`: baseline lane/mood behavior
+- `question`: unresolved lifecycle gets a return boost
+- `prompt`: keeps rotation lively; very old material cools
+- `repair`: strong recency bias for practical usefulness
+- `witness`: suppresses hyper-recency spikes; favors settled clarity
+- `oracle`: favors older absent artifacts; penalizes brand-new ones
+
+## Rule for future contributors
+
+If a change can be expressed as metadata, copy, or weighting, keep it inside this shared engine.
+Only split systems when runtime boundaries or trust boundaries genuinely diverge.
diff --git a/docs/MISSION_EXPANSION.md b/docs/MISSION_EXPANSION.md
new file mode 100644
index 0000000..e996faf
--- /dev/null
+++ b/docs/MISSION_EXPANSION.md
@@ -0,0 +1,87 @@
+# Mission Expansion: Memory Engine → Artifact Engine Family
+
+This repository still ships as **Memory Engine** first. That default is not being diluted.
+
+The opening in this pass is architectural and editorial: we now name the shared substrate as a **local-first artifact/offering engine** that can host multiple sibling deployments without breaking the current machine.
+
+## What stays the same
+
+- Local-first ingest, playback, and operator model.
+- Distinct surfaces and operational posture: `/kiosk/`, `/room/`, `/ops/`.
+- Memory Engine default behavior, default copy, and default policy tuning.
+- Existing operator workflow and route map.
+- Existing persistence model for artifacts, derivatives, and revocation.
+
+## What becomes configurable
+
+## Deployment catalog shape (explicit)
+
+Each deployment entry carries:
+
+- machine key (`memory`, `question`, `prompt`, `repair`, `witness`, `oracle`)
+- label and short description
+- participant/framing nouns
+- copy catalog reference
+- playback policy reference
+- ops-facing note
+
+This keeps extension work concrete: no plugin loaders, no abstract platform shell, just inspectable in-repo configuration.
+
+
+Deployment kind (`ENGINE_DEPLOYMENT`) is now a first-pass config primitive.
+
+Planned supported values:
+
+- `memory` (default, fully wired)
+- `question`
+- `prompt`
+- `repair`
+- `witness`
+- `oracle`
+
+In this pass, deployment kind is intentionally lightweight and used for:
+
+- participant copy selection hooks
+- prompt/intake framing hooks
+- future metadata branching hooks
+- future playback-policy branching hooks
+- operator-facing labeling
+
+## Why Question Engine is the same engine
+
+Question Engine should not be built as a separate system because it shares the same hard parts:
+
+- local capture and consent workflow
+- artifact storage + retention mechanics
+- room composition and replay loops
+- steward controls and safety posture
+
+What changes is mostly **policy and rhetoric**: intake framing, review language, artifact interpretation, and replay bias.
+
+## Plausible deployment family
+
+- **Memory Engine**: voice offerings for weathered room memory.
+- **Question Engine**: unresolved prompts and recurring asks.
+- **Prompt Engine**: authored prompt cycles and participant responses.
+- **Repair Engine**: practical notes, fixes, and resurfacing tasks.
+- **Witness Engine**: testimony-oriented offerings with trace stewardship.
+- **Oracle Engine**: sparse, ceremonial, cue-like resurfacing.
+
+## Deployment-defining system layers
+
+A deployment is defined by tuning these layers (not by rewriting the stack):
+
+1. **Intake framing** (kiosk attract language + recording invitation)
+2. **Artifact metadata** (what the offering is treated as)
+3. **Review language** (keep/discard rhetoric and consent framing)
+4. **Room playback/composition behavior** (selection, recurrence, rarity, layering)
+5. **Operator controls** (which tuning knobs are exposed per deployment)
+6. **Retention / afterlife posture** (what persists, for how long, in what form)
+7. **Responsiveness expectations** (immediate acknowledgement → near-immediate reflection → ambient afterlife)
+
+## Guardrails for contributors
+
+- Do not flatten Memory Engine into generic blandness.
+- Keep the code inspectable and local-first.
+- Prefer small policy seams over heavyweight abstraction frameworks.
+- Add deployment hooks only where they have immediate operational use.
diff --git a/docs/RESPONSIVENESS.md b/docs/RESPONSIVENESS.md
new file mode 100644
index 0000000..ec633e5
--- /dev/null
+++ b/docs/RESPONSIVENESS.md
@@ -0,0 +1,50 @@
+# Responsiveness Ladder (Design Principle)
+
+These machines are both public-facing instruments and learning tools. Every deployment should preserve a clear feedback ladder.
+
+## 1) Immediate acknowledgement (sub-second)
+
+Participant acts, machine responds *now*.
+
+Current examples:
+
+- arm/record state changes and mic status chips
+- meter response while waiting/recording
+- countdown and button state transitions
+
+## 2) Near-immediate reflection / preview
+
+Participant gets a fast reflection of what they just offered.
+
+Current examples:
+
+- review-stage playback preview
+- quiet-take warning and keep/retake decision
+- mode choice and submission status updates
+
+## 3) Ambient afterlife in room/archive
+
+Offering returns over time in the room/archive, not as instant UI confirmation.
+
+Current examples:
+
+- room loop resurfacing with wear and lane pacing
+- fossil/residue survival beyond raw-audio lifetime
+- operator summary visibility in `/ops/`
+
+## Practical guidance for new deployments
+
+- Never trade away step 1 to add complexity to step 3.
+- Keep the acknowledgement chain local and robust under weak network conditions.
+- Add deployment-specific behavior by adjusting policy/copy first, not by delaying baseline feedback.
+- Treat responsiveness as part of trust: participants need to know they were heard before any long-tail behavior occurs.
+
+## Cross-deployment rule
+
+Even when tone changes (`question` urgency, `repair` utility, `oracle` ceremony), the ladder does not change:
+
+1. immediate local acknowledgment
+2. near-immediate reflection
+3. ambient afterlife over time
+
+No deployment gets to skip step 1.
diff --git a/docs/maintenance.md b/docs/maintenance.md
index 2b24d32..a81cbae 100644
--- a/docs/maintenance.md
+++ b/docs/maintenance.md
@@ -98,6 +98,7 @@ Create a remote-friendly support bundle with logs and health snapshots:
- `docs/installation-checklist.md` is the install-day checklist for kiosk hardware, browser mode, audio routing, and auto-start verification.
- Django also validates runtime config relationships at startup now, so bad threshold ordering or insecure origin posture fails fast before the stack enters service.
- `INSTALLATION_PROFILE` can provide a named starting posture for room behavior and kiosk defaults. Explicit env vars still override profile defaults.
+- `ENGINE_DEPLOYMENT` declares the active deployment kind (`memory` default; also `question`, `prompt`, `repair`, `witness`, `oracle`) so `/ops/`, participant framing, artifact metadata, and playback weighting can branch safely without changing routes.
- Public write paths are also guarded by server-side WAV validation and two-layer DRF throttling: a kiosk-friendly client limit plus a broader IP abuse ceiling. If you tune those limits, update `INGEST_MAX_UPLOAD_BYTES`, `INGEST_MAX_DURATION_SECONDS`, `PUBLIC_INGEST_RATE`, `PUBLIC_INGEST_IP_RATE`, `PUBLIC_REVOKE_RATE`, and `PUBLIC_REVOKE_IP_RATE` together.
- `/ops/` now shows those configured budgets plus recent throttle hits, and `/kiosk/` shows a soft warning when the current station is nearing its remaining ingest budget.
- Leave `DJANGO_TRUST_X_FORWARDED_FOR=0` unless your reverse proxy strips and rewrites forwarded headers correctly. If you turn it on, throttling and steward network allowlists will trust that header.
diff --git a/docs/roadmap.md b/docs/roadmap.md
index eb44588..c443be1 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -49,6 +49,15 @@
- Reduced-motion handling for the countdown and kiosk transitions
- Steward-configurable max recording duration instead of keeping it browser-only
+
+### Mission opening: deployment family groundwork
+- First-pass deployment-kind config (`ENGINE_DEPLOYMENT`) with `memory` as explicit default and planned sibling kinds (`question`, `prompt`, `repair`, `witness`, `oracle`)
+- Deployment-aware participant copy seams so memory-specific rhetoric can be overridden without rewriting the kiosk flow
+- Deployment-aware operator posture so `/ops/` can show active deployment now and gain mode-specific tuning later
+- Docs that define Memory Engine as the default deployment of a broader local-first artifact engine (opening, not rebrand)
+- Playback policy framing that separates shared room-loop infrastructure from deployment-level behavior intent
+- Responsiveness ladder documented as a cross-deployment requirement (immediate acknowledgement, near-immediate reflection, ambient afterlife)
+
### Audience playback
- Weighted pool selection with cooldown to reduce obvious repetition
- Selection weighting that also accounts for age and recentness, so the room favors settled material without locking into the oldest memories
@@ -94,6 +103,13 @@
## Next
+### Multi-deployment follow-through
+- Add deployment-specific intake cards so stewards can tune prompts without touching code
+- Add lightweight deployment-aware metadata editing in `/ops/` (topic + lifecycle only)
+- Add deployment-aware playback policy presets visible in operator status exports
+- Add deployment-aware retention/export presets for archival handoff bundles
+- Add installation-specific room identities that can be combined with deployment kind (e.g., `repair` + `shared_lab`)
+
### User / speaker
- Add optional headphone or monitor-check mode for setup and microphone testing
@@ -110,12 +126,14 @@
### User / speaker
- Add participant-facing revocation guidance that can be shown without exposing full steward controls
- Add installation-specific speaker prompts or writing prompts that can shift the emotional tone of the room
+- Add deployment-specific prompt packs so `memory`, `question`, and `repair` can diverge without branching app logic
- Add optional steward-authored session themes that influence idle copy and submission framing
- Add alternate kiosk layouts for seated booths, standing kiosks, and wall-mounted enclosures
### Audience / room effect
- Add audience-presence or ambient-volume sensing so the room can react to actual occupancy
-- Add installation-specific "personalities" that package movement, tone, gap, and wear behavior together
+- Add installation-specific and deployment-specific "personalities" that package movement, tone, gap, and wear behavior together
+- Add deployment-specific playback policies (recurrence, recency, rarity, clustering) on top of the shared room loop
- Add shared-pool or federated-pool options for multi-room installations
- Add richer visual layers such as projected fossils, spectrogram drift, or low-light companion displays
- Add semantic or transcript-aware grouping if metadata-only composition plateaus
@@ -127,7 +145,9 @@
- Add structured export bundles with manifests, checksums, and import instructions for archival handoff
- Add multi-node stewardship tooling if more than one installation is deployed
- Add role-based steward access if the installation grows beyond one trusted operator
-- Add long-term retention policy controls that can differ by consent mode or installation
+- Add deployment-aware operator controls so stewardship UI can expose mode-specific tuning safely
+- Add long-term retention policy controls that can differ by consent mode, artifact type, or installation
+- Add artifact-type-aware export posture so handoff bundles can preserve deployment semantics
- Add a documented disaster-recovery rehearsal flow rather than only backup and restore commands
- Add a fuller external-storage migration story for moving beyond MinIO if scale or policy changes
diff --git a/frontend-tests/kiosk-copy.test.js b/frontend-tests/kiosk-copy.test.js
new file mode 100644
index 0000000..94abf80
--- /dev/null
+++ b/frontend-tests/kiosk-copy.test.js
@@ -0,0 +1,37 @@
+const test = require("node:test");
+const assert = require("node:assert/strict");
+const fs = require("node:fs");
+const path = require("node:path");
+const vm = require("node:vm");
+
+function loadCopyApi() {
+ const scriptPath = path.join(__dirname, "../api/engine/static/engine/kiosk-copy.js");
+ const script = fs.readFileSync(scriptPath, "utf8");
+ const context = { window: {} };
+ vm.runInNewContext(script, context, { filename: scriptPath });
+ return context.window.MemoryEngineKioskCopy;
+}
+
+test("deployment copy defaults to memory pack", () => {
+ const copy = loadCopyApi();
+ const base = copy.getPack("en");
+ const deploymentPack = copy.getDeploymentPack("en", "memory");
+
+ assert.equal(deploymentPack.heroTitle, base.heroTitle);
+ assert.equal(copy.normalizeDeployment(""), "memory");
+ assert.equal(deploymentPack.deploymentLabel, "Memory Engine");
+});
+
+test("all six deployment packs are available with mode copy", () => {
+ const copy = loadCopyApi();
+ const modes = ["memory", "question", "prompt", "repair", "witness", "oracle"];
+
+ for (const deployment of modes) {
+ const pack = copy.getDeploymentPack("en", deployment);
+ assert.ok(pack.heroTitle);
+ assert.ok(pack.btnChooseMemoryMode);
+ assert.ok(pack.modes.ROOM.name);
+ assert.ok(pack.modes.FOSSIL.name);
+ assert.ok(pack.modes.NOSAVE.name);
+ }
+});
diff --git a/frontend-tests/operator-dashboard.test.js b/frontend-tests/operator-dashboard.test.js
index 31d6c08..8dc29d3 100644
--- a/frontend-tests/operator-dashboard.test.js
+++ b/frontend-tests/operator-dashboard.test.js
@@ -89,12 +89,14 @@ test("artifactSummaryCards summarize totals, lanes, and dominant moods", () => {
},
});
- assert.equal(cards.length, 3);
- assert.equal(cards[0].title, "Archive totals");
- assert.match(cards[0].detail, /12 active/);
- assert.equal(cards[1].title, "Lane balance");
- assert.match(cards[1].detail, /fresh 3/);
- assert.equal(cards[2].title, "Dominant moods");
- assert.match(cards[2].detail, /hushed 4/);
- assert.match(cards[2].detail, /weathered 3/);
+ assert.equal(cards.length, 4);
+ assert.equal(cards[0].title, "Active deployment");
+ assert.match(cards[0].detail, /memory/i);
+ assert.equal(cards[1].title, "Archive totals");
+ assert.match(cards[1].detail, /12 active/);
+ assert.equal(cards[2].title, "Lane balance");
+ assert.match(cards[2].detail, /fresh 3/);
+ assert.equal(cards[3].title, "Dominant moods");
+ assert.match(cards[3].detail, /hushed 4/);
+ assert.match(cards[3].detail, /weathered 3/);
});