From eb5cc215d3255f265ab69908c6d106236da05479 Mon Sep 17 00:00:00 2001 From: Ben Severns Date: Mon, 30 Mar 2026 13:56:08 -0500 Subject: [PATCH 1/2] Open artifact-engine deployment seams while keeping memory default --- .env.example | 5 ++ README.md | 16 ++++ api/engine/api_views.py | 9 +++ api/engine/static/engine/kiosk-copy.js | 44 +++++++++++ api/engine/static/engine/kiosk.js | 3 +- .../static/engine/operator-dashboard.js | 7 ++ .../templates/engine/operator_dashboard.html | 1 + api/engine/tests/base.py | 1 + api/engine/tests/test_config.py | 17 +++++ api/engine/tests/test_ops.py | 8 ++ api/engine/views.py | 15 ++++ api/memory_engine/config_validation.py | 5 ++ api/memory_engine/deployments.py | 69 ++++++++++++++++++ api/memory_engine/settings.py | 5 ++ docs/DEPLOYMENT_BEHAVIORS.md | 53 ++++++++++++++ docs/MISSION_EXPANSION.md | 73 +++++++++++++++++++ docs/RESPONSIVENESS.md | 40 ++++++++++ docs/maintenance.md | 1 + docs/roadmap.md | 17 ++++- frontend-tests/kiosk-copy.test.js | 33 +++++++++ frontend-tests/operator-dashboard.test.js | 18 +++-- 21 files changed, 429 insertions(+), 11 deletions(-) create mode 100644 api/memory_engine/deployments.py create mode 100644 docs/DEPLOYMENT_BEHAVIORS.md create mode 100644 docs/MISSION_EXPANSION.md create mode 100644 docs/RESPONSIVENESS.md create mode 100644 frontend-tests/kiosk-copy.test.js diff --git a/.env.example b/.env.example index 28e2a81..9b8bbe2 100644 --- a/.env.example +++ b/.env.example @@ -52,6 +52,11 @@ 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 + # Postgres POSTGRES_DB=memory_engine POSTGRES_USER=memory_engine diff --git a/README.md b/README.md index c66e4a4..e53f1dd 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 @@ -231,6 +233,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 +436,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..7538a09 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 @@ -591,8 +593,15 @@ 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, + }, "components": components, "operator_state": operator_state, "active": active, diff --git a/api/engine/static/engine/kiosk-copy.js b/api/engine/static/engine/kiosk-copy.js index dea395d..18aac77 100644 --- a/api/engine/static/engine/kiosk-copy.js +++ b/api/engine/static/engine/kiosk-copy.js @@ -426,6 +426,39 @@ }, }; + + + const DEPLOYMENT_OVERRIDES = { + memory: {}, + question: { + en: { + heroTitle: "Room Questions", + heroSub: "A quiet place to leave a question for this room. Record once, review once, then let it circulate.", + stageIdleTitle: "When you are ready, wake the question microphone.", + stageReviewTitle: "Listen back, then choose what follows this question.", + btnChooseMemoryMode: "Choose a question mode", + 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.", + }, + }, + repair: { + en: { + heroTitle: "Room Repair", + heroSub: "A practical station for recording what needs fixing, restoring, or keeping in working memory.", + stageIdleTitle: "When you are ready, wake the repair microphone.", + stageReviewTitle: "Listen back, then choose how this repair note should be handled.", + btnChooseMemoryMode: "Choose a repair mode", + 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.", + }, + }, + }; + + 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 +472,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 +494,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..0124575 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"})`, + }, { 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..fa75835 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 }}). Future deployment-specific tuning will live alongside these controls without replacing the current stewardship flow.

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_config.py b/api/engine/tests/test_config.py index 08fdac4..a6b085e 100644 --- a/api/engine/tests/test_config.py +++ b/api/engine/tests/test_config.py @@ -1,5 +1,6 @@ from django.core.exceptions import ImproperlyConfigured +from memory_engine.deployments import DEFAULT_ENGINE_DEPLOYMENT, available_engine_deployments from memory_engine.installation_profiles import installation_profile_default from .base import EngineTestCase, default_runtime_config, validate_runtime_settings @@ -65,6 +66,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 +125,14 @@ 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"), + ) + def test_installation_profile_defaults_return_expected_values(self): self.assertEqual( installation_profile_default("shared_lab", "KIOSK_DEFAULT_MAX_RECORDING_SECONDS", 120), diff --git a/api/engine/tests/test_ops.py b/api/engine/tests/test_ops.py index 6a9ca90..47fa5d6 100644 --- a/api/engine/tests/test_ops.py +++ b/api/engine/tests/test_ops.py @@ -265,6 +265,14 @@ 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") + @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/views.py b/api/engine/views.py index 2ccaf69..76288a8 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_SPECS, 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,12 @@ def room_surface_config(): tone_source_url=settings.ROOM_TONE_SOURCE_URL, ) return { + "engineDeployment": active_deployment.code, + "engineDeploymentLabel": active_deployment.label, + "engineDeploymentCatalog": [ + {"code": spec.code, "label": spec.label, "description": spec.short_description} + for spec in DEPLOYMENT_SPECS + ], "kioskLanguageCode": str(getattr(settings, "KIOSK_DEFAULT_LANGUAGE_CODE", "en")), "kioskMaxRecordingSeconds": int(getattr(settings, "KIOSK_DEFAULT_MAX_RECORDING_SECONDS", 120)), "roomIntensityProfile": schedule["intensityProfile"], @@ -127,8 +136,14 @@ 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, + }, }) 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..5bea479 --- /dev/null +++ b/api/memory_engine/deployments.py @@ -0,0 +1,69 @@ +"""Deployment catalog for artifact-engine variants. + +Memory Engine remains the canonical default. This module intentionally keeps the +shape small so future deployments can branch copy/policy without forcing a broad +rewrite today. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +DEFAULT_ENGINE_DEPLOYMENT = "memory" + + +@dataclass(frozen=True) +class DeploymentSpec: + code: str + label: str + short_description: str + + +DEPLOYMENT_SPECS: tuple[DeploymentSpec, ...] = ( + DeploymentSpec( + code="memory", + label="Memory Engine", + short_description="Room-memory default with weathering and temporal depth.", + ), + DeploymentSpec( + code="question", + label="Question Engine", + short_description="Question-forward capture where unresolved returns can be emphasized.", + ), + DeploymentSpec( + code="prompt", + label="Prompt Engine", + short_description="Prompt-led intake where authored cues steer artifact framing.", + ), + DeploymentSpec( + code="repair", + label="Repair Engine", + short_description="Repair-focused capture tuned for practical resurfacing and recency utility.", + ), + DeploymentSpec( + code="witness", + label="Witness Engine", + short_description="Witness posture for testimony-like offerings and trace stewardship.", + ), + DeploymentSpec( + code="oracle", + label="Oracle Engine", + short_description="Ceremonial, sparse reappearance posture for prompt-like returns.", + ), +) + +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]) 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..dd83e1a --- /dev/null +++ b/docs/DEPLOYMENT_BEHAVIORS.md @@ -0,0 +1,53 @@ +# Deployment Behaviors and Afterlife Posture + +This note defines playback/afterlife behavior as a **deployment concern**. + +Memory Engine already implements substantial real behavior. Future sibling deployments should branch from the same loop and stewardship machinery, not fork into separate stacks. + +## Already real in Memory Engine + +Current system behavior already includes: + +- wear/decay dynamics across repeated playback +- fresh/mid/worn lane balancing +- cooldown + anti-repetition controls +- movement and daypart pacing +- scarcity and quiet-hours posture +- fossil/residue afterlife for non-room-full retention + +## Behavior sketches for sibling deployments (not fully implemented yet) + +### Memory Engine (`memory`) +- Weathering, patina, temporal depth. +- Return logic that rewards age and absence. + +### Question Engine (`question`) +- Recurrence and unresolved return. +- Clustering around question-like artifacts. +- "Haunting" behavior where unresolved offerings reappear. + +### Repair Engine (`repair`) +- Practical resurfacing with recency bias. +- Utility-forward playback windows. +- Faster re-cue cycles for actionable offerings. + +### Oracle Engine (`oracle`) +- Rarity and ceremonial timing. +- Prompt-like resurfacing events. +- Sparse but high-signal reappearance. + +### Prompt / Witness (planned) +- Prompt: authored cadence and response waves. +- Witness: trace-preserving replay with stewardship-aware pacing. + +## Where future policy hooks should live + +- `api/memory_engine/deployments.py` for deployment catalog and labels +- kiosk copy selection via deployment-aware lookup in `kiosk-copy.js` +- playback policy branching at room loop policy/composer boundaries (`room_composer.py`, room loop policy JS) +- operator labels and eventual deployment-specific controls in `/ops/` +- retention/export policy branching in steward and reporting layers + +## Rule of thumb + +If a behavior change can be represented as policy, copy, metadata, or weighting, keep it inside this engine family. Only split systems if the runtime or trust boundary fundamentally changes. diff --git a/docs/MISSION_EXPANSION.md b/docs/MISSION_EXPANSION.md new file mode 100644 index 0000000..7ad5fa2 --- /dev/null +++ b/docs/MISSION_EXPANSION.md @@ -0,0 +1,73 @@ +# 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 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..bac6f65 --- /dev/null +++ b/docs/RESPONSIVENESS.md @@ -0,0 +1,40 @@ +# 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. diff --git a/docs/maintenance.md b/docs/maintenance.md index 2b24d32..f1677b6 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; planned: `question`, `prompt`, `repair`, `witness`, `oracle`) so `/ops/` and participant framing 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..0758752 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 @@ -110,12 +119,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 +138,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..92aa630 --- /dev/null +++ b/frontend-tests/kiosk-copy.test.js @@ -0,0 +1,33 @@ +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"); +}); + +test("question and repair deployment overrides are available as placeholders", () => { + const copy = loadCopyApi(); + const question = copy.getDeploymentPack("en", "question"); + const repair = copy.getDeploymentPack("en", "repair"); + + assert.match(question.heroTitle, /Question/i); + assert.match(question.btnChooseMemoryMode, /question mode/i); + assert.match(repair.heroTitle, /Repair/i); + assert.match(repair.btnChooseMemoryMode, /repair mode/i); +}); 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/); }); From 90a00ff8728af2ff7963aab1571cd1434e20a386 Mon Sep 17 00:00:00 2001 From: Ben Severns Date: Mon, 30 Mar 2026 14:15:12 -0500 Subject: [PATCH 2/2] Add explicit multi-deployment support across copy, metadata, and playback hooks --- .env.example | 1 + README.md | 24 +++ api/engine/api_views.py | 28 ++- api/engine/deployment_policy.py | 48 +++++ .../0010_artifact_deployment_metadata.py | 30 +++ api/engine/models.py | 6 + api/engine/pool.py | 34 ++- api/engine/reporting.py | 4 + api/engine/serializers.py | 3 +- api/engine/static/engine/kiosk-copy.js | 198 +++++++++++++++++- .../static/engine/operator-dashboard.js | 2 +- .../templates/engine/operator_dashboard.html | 2 +- api/engine/tests/test_artifacts.py | 22 ++ api/engine/tests/test_config.py | 17 +- api/engine/tests/test_ops.py | 1 + api/engine/tests/test_pool.py | 44 ++++ api/engine/views.py | 11 +- api/memory_engine/deployments.py | 68 +++++- docs/DEPLOYMENT_BEHAVIORS.md | 69 +++--- docs/MISSION_EXPANSION.md | 14 ++ docs/RESPONSIVENESS.md | 10 + docs/maintenance.md | 2 +- docs/roadmap.md | 7 + frontend-tests/kiosk-copy.test.js | 18 +- 24 files changed, 589 insertions(+), 74 deletions(-) create mode 100644 api/engine/deployment_policy.py create mode 100644 api/engine/migrations/0010_artifact_deployment_metadata.py diff --git a/.env.example b/.env.example index 9b8bbe2..6ead379 100644 --- a/.env.example +++ b/.env.example @@ -56,6 +56,7 @@ INSTALLATION_PROFILE=custom # 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 diff --git a/README.md b/README.md index e53f1dd..853d254 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,30 @@ This repo now opens one layer wider: **Memory Engine is still the default deploy - 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: diff --git a/api/engine/api_views.py b/api/engine/api_views.py index 7538a09..ce794e7 100644 --- a/api/engine/api_views.py +++ b/api/engine/api_views.py @@ -109,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) @@ -191,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) @@ -216,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" @@ -266,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) @@ -291,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, ) @@ -399,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" @@ -438,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) @@ -601,6 +625,8 @@ def node_status(request): "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, 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 18aac77..4817bf2 100644 --- a/api/engine/static/engine/kiosk-copy.js +++ b/api/engine/static/engine/kiosk-copy.js @@ -429,27 +429,219 @@ const DEPLOYMENT_OVERRIDES = { - memory: {}, + memory: { + en: { + deploymentLabel: "Memory Engine", + }, + }, question: { en: { + deploymentLabel: "Question Engine", + documentTitle: "Question Engine - Recording Station", heroTitle: "Room Questions", - heroSub: "A quiet place to leave a question for this room. Record once, review once, then let it circulate.", + 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: "A practical station for recording what needs fixing, restoring, or keeping in working memory.", + 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.", + }, + }, }, }, }; diff --git a/api/engine/static/engine/operator-dashboard.js b/api/engine/static/engine/operator-dashboard.js index 0124575..c803544 100644 --- a/api/engine/static/engine/operator-dashboard.js +++ b/api/engine/static/engine/operator-dashboard.js @@ -221,7 +221,7 @@ tagName: "article", className: "component-card ready", title: "Active deployment", - detail: `${deployment.label || "Memory Engine"} (${deployment.code || "memory"})`, + detail: `${deployment.label || "Memory Engine"} (${deployment.code || "memory"}) · ${deployment.description || "room-memory default posture"}`, }, { tagName: "article", diff --git a/api/engine/templates/engine/operator_dashboard.html b/api/engine/templates/engine/operator_dashboard.html index fa75835..ac7b8f0 100644 --- a/api/engine/templates/engine/operator_dashboard.html +++ b/api/engine/templates/engine/operator_dashboard.html @@ -12,7 +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 }}). Future deployment-specific tuning will live alongside these controls without replacing the current stewardship flow.

+

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/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 a6b085e..948ac3b 100644 --- a/api/engine/tests/test_config.py +++ b/api/engine/tests/test_config.py @@ -1,6 +1,7 @@ +from pathlib import Path from django.core.exceptions import ImproperlyConfigured -from memory_engine.deployments import DEFAULT_ENGINE_DEPLOYMENT, available_engine_deployments +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 @@ -10,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, @@ -132,6 +137,10 @@ def test_engine_deployment_catalog_includes_planned_modes(self): 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( @@ -148,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 47fa5d6..3594026 100644 --- a/api/engine/tests/test_ops.py +++ b/api/engine/tests/test_ops.py @@ -272,6 +272,7 @@ def test_node_status_reports_active_engine_deployment(self): 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): 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 76288a8..94bd456 100644 --- a/api/engine/views.py +++ b/api/engine/views.py @@ -2,7 +2,7 @@ from django.shortcuts import redirect from django.shortcuts import render -from memory_engine.deployments import DEPLOYMENT_SPECS, deployment_spec +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 @@ -41,10 +41,9 @@ def room_surface_config(): return { "engineDeployment": active_deployment.code, "engineDeploymentLabel": active_deployment.label, - "engineDeploymentCatalog": [ - {"code": spec.code, "label": spec.label, "description": spec.short_description} - for spec in DEPLOYMENT_SPECS - ], + "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"], @@ -143,6 +142,8 @@ def operator_dashboard_view(request): "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/deployments.py b/api/memory_engine/deployments.py index 5bea479..6e492e4 100644 --- a/api/memory_engine/deployments.py +++ b/api/memory_engine/deployments.py @@ -1,8 +1,7 @@ -"""Deployment catalog for artifact-engine variants. +"""Deployment catalog for artifact/offering engine variants. -Memory Engine remains the canonical default. This module intentionally keeps the -shape small so future deployments can branch copy/policy without forcing a broad -rewrite today. +Memory Engine remains the canonical default. This module intentionally stays +small and explicit so deployment differences remain inspectable. """ from __future__ import annotations @@ -17,6 +16,11 @@ 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, ...] = ( @@ -24,31 +28,61 @@ class 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="Question-forward capture where unresolved returns can be emphasized.", + 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 steer artifact framing.", + 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="Repair-focused capture tuned for practical resurfacing and recency utility.", + 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="Witness posture for testimony-like offerings and trace stewardship.", + 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 reappearance posture for prompt-like returns.", + 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.", ), ) @@ -67,3 +101,19 @@ def normalize_engine_deployment_name(value: str | None) -> str: 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/docs/DEPLOYMENT_BEHAVIORS.md b/docs/DEPLOYMENT_BEHAVIORS.md index dd83e1a..880b3bc 100644 --- a/docs/DEPLOYMENT_BEHAVIORS.md +++ b/docs/DEPLOYMENT_BEHAVIORS.md @@ -1,53 +1,38 @@ # Deployment Behaviors and Afterlife Posture -This note defines playback/afterlife behavior as a **deployment concern**. +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. -Memory Engine already implements substantial real behavior. Future sibling deployments should branch from the same loop and stewardship machinery, not fork into separate stacks. +## Deployment quick map -## Already real in Memory Engine +| 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 | -Current system behavior already includes: +## Already real in code -- wear/decay dynamics across repeated playback -- fresh/mid/worn lane balancing -- cooldown + anti-repetition controls -- movement and daypart pacing -- scarcity and quiet-hours posture -- fossil/residue afterlife for non-room-full retention +- 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` -## Behavior sketches for sibling deployments (not fully implemented yet) +## Playback hook posture (small and intentional) -### Memory Engine (`memory`) -- Weathering, patina, temporal depth. -- Return logic that rewards age and absence. +Current deployment policies are weighting adjustments, not a second engine: -### Question Engine (`question`) -- Recurrence and unresolved return. -- Clustering around question-like artifacts. -- "Haunting" behavior where unresolved offerings reappear. +- `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 -### Repair Engine (`repair`) -- Practical resurfacing with recency bias. -- Utility-forward playback windows. -- Faster re-cue cycles for actionable offerings. +## Rule for future contributors -### Oracle Engine (`oracle`) -- Rarity and ceremonial timing. -- Prompt-like resurfacing events. -- Sparse but high-signal reappearance. - -### Prompt / Witness (planned) -- Prompt: authored cadence and response waves. -- Witness: trace-preserving replay with stewardship-aware pacing. - -## Where future policy hooks should live - -- `api/memory_engine/deployments.py` for deployment catalog and labels -- kiosk copy selection via deployment-aware lookup in `kiosk-copy.js` -- playback policy branching at room loop policy/composer boundaries (`room_composer.py`, room loop policy JS) -- operator labels and eventual deployment-specific controls in `/ops/` -- retention/export policy branching in steward and reporting layers - -## Rule of thumb - -If a behavior change can be represented as policy, copy, metadata, or weighting, keep it inside this engine family. Only split systems if the runtime or trust boundary fundamentally changes. +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 index 7ad5fa2..e996faf 100644 --- a/docs/MISSION_EXPANSION.md +++ b/docs/MISSION_EXPANSION.md @@ -14,6 +14,20 @@ The opening in this pass is architectural and editorial: we now name the shared ## 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: diff --git a/docs/RESPONSIVENESS.md b/docs/RESPONSIVENESS.md index bac6f65..ec633e5 100644 --- a/docs/RESPONSIVENESS.md +++ b/docs/RESPONSIVENESS.md @@ -38,3 +38,13 @@ Current examples: - 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 f1677b6..a81cbae 100644 --- a/docs/maintenance.md +++ b/docs/maintenance.md @@ -98,7 +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; planned: `question`, `prompt`, `repair`, `witness`, `oracle`) so `/ops/` and participant framing can branch safely without changing routes. +- `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 0758752..c443be1 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -103,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 diff --git a/frontend-tests/kiosk-copy.test.js b/frontend-tests/kiosk-copy.test.js index 92aa630..94abf80 100644 --- a/frontend-tests/kiosk-copy.test.js +++ b/frontend-tests/kiosk-copy.test.js @@ -19,15 +19,19 @@ test("deployment copy defaults to memory pack", () => { assert.equal(deploymentPack.heroTitle, base.heroTitle); assert.equal(copy.normalizeDeployment(""), "memory"); + assert.equal(deploymentPack.deploymentLabel, "Memory Engine"); }); -test("question and repair deployment overrides are available as placeholders", () => { +test("all six deployment packs are available with mode copy", () => { const copy = loadCopyApi(); - const question = copy.getDeploymentPack("en", "question"); - const repair = copy.getDeploymentPack("en", "repair"); + const modes = ["memory", "question", "prompt", "repair", "witness", "oracle"]; - assert.match(question.heroTitle, /Question/i); - assert.match(question.btnChooseMemoryMode, /question mode/i); - assert.match(repair.heroTitle, /Repair/i); - assert.match(repair.btnChooseMemoryMode, /repair mode/i); + 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); + } });