Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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).
37 changes: 36 additions & 1 deletion api/engine/api_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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"
Expand Down Expand Up @@ -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)
Expand All @@ -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,
)
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
48 changes: 48 additions & 0 deletions api/engine/deployment_policy.py
Original file line number Diff line number Diff line change
@@ -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
30 changes: 30 additions & 0 deletions api/engine/migrations/0010_artifact_deployment_metadata.py
Original file line number Diff line number Diff line change
@@ -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"),
),
]
6 changes: 6 additions & 0 deletions api/engine/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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):
Expand Down
34 changes: 32 additions & 2 deletions api/engine/pool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand All @@ -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

Expand All @@ -281,6 +310,7 @@ def select_pool_artifact(
cooldown_seconds,
preferred_mood,
recent_densities=recent_densities,
deployment_code=deployment.code,
)
for artifact in candidates
]
Expand Down
4 changes: 4 additions & 0 deletions api/engine/reporting.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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),
}
Loading
Loading