Skip to content

Commit c5565a1

Browse files
committed
Embed the akousmata history view in the dashboard
- new left-side Akousmata section: search + compact cards over the shared store, with a detail modal (listenings with contract pins, lineage, kinship, audio playback, and the three germ buttons) — the same library the standalone akousmata navigator serves, natively inside oída - backed by optional /akousmata/* routes (oida/akousmata_view.py) over py-akousma; card shapes stay compatible with the navigator; read-only by design (oída writes through its listen flow and the germ bridge) - 133 tests + 5 subtests green; ruff clean
1 parent 7546c13 commit c5565a1

6 files changed

Lines changed: 365 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@
22

33
## 0.2.0 - Unreleased
44

5+
- **Akousmata history embedded in the dashboard**: a new left-side
6+
"Akousmata" section browses the shared store natively inside oída (search,
7+
compact cards, and a detail modal with listenings, lineage, kinship, audio
8+
playback, and the three germ buttons) — the same library the standalone
9+
akousmata navigator (`github.com/sonicfieldlabs/akousmata`) serves, without
10+
launching the external app. Backed by new optional `/akousmata/*` routes
11+
(`oida/akousmata_view.py`) over py-akousma; card shapes stay compatible
12+
with the navigator. Read-only by design: oída writes through its listen
13+
flow and the germ bridge; edits belong to the navigator.
14+
515
- **Preset ids aligned with AKOÚŌ v0.6's portable vocabulary**:
616
`environment``field`, `speech``voice`, `memory``recall` (with a
717
`LEGACY_PRESET_ALIASES` map so saved configs, sessions, and older clients

oida/akousmata_view.py

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
"""The akousmata history view inside oída.
2+
3+
The shared store has its own app (the akousmata listening navigator,
4+
github.com/sonicfieldlabs/akousmata); oída embeds the same library view —
5+
list, filter, detail with lineage and kinship, audio playback — natively in
6+
its dashboard instead of launching the external app. Card shapes stay
7+
compatible with the navigator's. Read-only here: oída WRITES memories through
8+
its listen flow and the germ bridge, and edits belong to the navigator.
9+
10+
Lazy on the ``akousma`` package like the germ bridge: oída boots without it
11+
and these routes degrade to 503.
12+
"""
13+
from __future__ import annotations
14+
15+
from pathlib import Path
16+
from typing import Any
17+
18+
19+
def _akousma():
20+
try:
21+
import akousma
22+
except ModuleNotFoundError as exc: # pragma: no cover - environment-dependent
23+
raise RuntimeError(
24+
"the 'akousma' package is not installed; "
25+
"pip install -e <SFL>/earworm/packages/py-akousma"
26+
) from exc
27+
return akousma
28+
29+
30+
def summary_line(record: dict[str, Any]) -> str:
31+
summary = record.get("summary")
32+
if isinstance(summary, str) and summary.strip():
33+
return summary.strip()
34+
for entry in (record.get("listening") or {}).values():
35+
if isinstance(entry, dict):
36+
text = entry.get("summary")
37+
if isinstance(text, str) and text.strip():
38+
return text.strip()
39+
payload = entry.get("payload") if isinstance(entry.get("payload"), dict) else entry
40+
for key in ("caption", "summary", "brief", "main_reading", "notes"):
41+
value = payload.get(key) if isinstance(payload, dict) else None
42+
if isinstance(value, str) and value.strip():
43+
return value.strip()
44+
prompt = (record.get("lineage") or {}).get("prompt")
45+
if isinstance(prompt, str) and prompt.strip():
46+
return prompt.strip()
47+
return ", ".join(str(t) for t in record.get("tags") or []) or "(no summary)"
48+
49+
50+
def card(record: dict[str, Any]) -> dict[str, Any]:
51+
provenance = record.get("provenance") or {}
52+
audio = record.get("audio") or {}
53+
lineage = record.get("lineage") or {}
54+
return {
55+
"akousma_id": record["akousma_id"],
56+
"created_at": record.get("created_at"),
57+
"summary": summary_line(record),
58+
"tags": list(record.get("tags") or []),
59+
"originating_app": provenance.get("originating_app"),
60+
"origin": provenance.get("origin"),
61+
"source_type": provenance.get("source_type"),
62+
"duration_seconds": audio.get("duration_seconds"),
63+
"has_audio": bool(audio.get("uri")),
64+
"parent_count": len(lineage.get("parent_akousma_ids") or []),
65+
"relation_count": len(lineage.get("relations") or []),
66+
}
67+
68+
69+
def _resolve_audio(store, record: dict[str, Any]) -> Path | None:
70+
uri = str((record.get("audio") or {}).get("uri") or "")
71+
if uri.startswith("akousmata://"):
72+
path = store.resolve_uri(uri)
73+
return path if path is not None and path.exists() else None
74+
if uri.startswith("file://"):
75+
path = Path(uri[7:])
76+
return path if path.exists() else None
77+
if uri and Path(uri).expanduser().exists():
78+
return Path(uri).expanduser()
79+
return None
80+
81+
82+
def _ref(store, akousma_id: str) -> dict[str, Any]:
83+
record = store.get(akousma_id)
84+
if record is None:
85+
return {"akousma_id": akousma_id, "summary": "(missing record)", "missing": True}
86+
return {"akousma_id": akousma_id, "summary": summary_line(record), "missing": False}
87+
88+
89+
def build_akousmata_router():
90+
from fastapi import APIRouter, HTTPException
91+
from fastapi.responses import FileResponse
92+
93+
akousma = _akousma()
94+
router = APIRouter(prefix="/akousmata", tags=["akousmata"])
95+
96+
def _store():
97+
return akousma.AkousmataStore()
98+
99+
@router.get("/records")
100+
def list_records(
101+
app: str | None = None,
102+
origin: str | None = None,
103+
tag: str | None = None,
104+
text: str | None = None,
105+
limit: int = 100,
106+
) -> dict[str, Any]:
107+
store = _store()
108+
try:
109+
try:
110+
found = store.query(originating_app=app, origin=origin, tag=tag, text=text, limit=max(1, min(limit, 500)))
111+
except TypeError: # pre-v0.2 store without tag/text filters
112+
found = store.query(originating_app=app, origin=origin, limit=max(1, min(limit, 500)))
113+
return {"records": [card(r) for r in found]}
114+
finally:
115+
store.close()
116+
117+
@router.get("/tags")
118+
def tags() -> dict[str, Any]:
119+
store = _store()
120+
try:
121+
return {"tags": store.tags() if hasattr(store, "tags") else []}
122+
finally:
123+
store.close()
124+
125+
@router.get("/records/{akousma_id}")
126+
def detail(akousma_id: str) -> dict[str, Any]:
127+
store = _store()
128+
try:
129+
record = store.get(akousma_id)
130+
if record is None:
131+
raise HTTPException(status_code=404, detail=f"akousma not found: {akousma_id}")
132+
related = store.related(akousma_id) if hasattr(store, "related") else []
133+
return {
134+
"record": record,
135+
"summary": summary_line(record),
136+
"parents": [_ref(store, pid) for pid in store.parents(akousma_id)],
137+
"children": [_ref(store, cid) for cid in store.children(akousma_id)],
138+
"related": [
139+
{**link, "summary": _ref(store, link.get("akousma_id", ""))["summary"]}
140+
for link in related
141+
],
142+
"audio_available": _resolve_audio(store, record) is not None,
143+
}
144+
finally:
145+
store.close()
146+
147+
@router.get("/audio/{akousma_id}")
148+
def audio(akousma_id: str):
149+
store = _store()
150+
try:
151+
record = store.get(akousma_id)
152+
if record is None:
153+
raise HTTPException(status_code=404, detail=f"akousma not found: {akousma_id}")
154+
path = _resolve_audio(store, record)
155+
if path is None:
156+
raise HTTPException(status_code=404, detail="no resolvable audio for this memory")
157+
return FileResponse(path)
158+
finally:
159+
store.close()
160+
161+
return router

oida/server.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,14 @@ async def lifespan(_app: Any):
500500
except ImportError:
501501
pass # akousma package not installed; oída still boots without the bridge
502502

503+
# Shared-store history view (the akousmata library, embedded); optional.
504+
try:
505+
from .akousmata_view import build_akousmata_router
506+
507+
app.include_router(build_akousmata_router())
508+
except (ImportError, RuntimeError):
509+
pass # akousma package not installed; oída still boots without the view
510+
503511
wildcard_bind = str(config.host) in {"0.0.0.0", "::", ""}
504512
if wildcard_bind and not config.auth_token:
505513
raise RuntimeError(

oida/static/app.js

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1281,13 +1281,117 @@ ui.memorySearch.addEventListener("keydown", (event) => {
12811281
if (event.key === "Enter") refreshMemory(ui.memorySearch.value.trim() || undefined);
12821282
});
12831283

1284+
/* ─────────────── akousmata: the shared library, embedded ─────────────── */
1285+
1286+
const akousmataUi = {
1287+
list: document.getElementById("akousmataList"),
1288+
search: document.getElementById("akousmataSearch"),
1289+
go: document.getElementById("akousmataGo"),
1290+
note: document.getElementById("akousmataNote"),
1291+
modal: document.getElementById("akousmataModal"),
1292+
title: document.getElementById("akousmataTitle"),
1293+
detail: document.getElementById("akousmataDetail"),
1294+
};
1295+
1296+
async function refreshAkousmata(query) {
1297+
if (!akousmataUi.list) return;
1298+
try {
1299+
const url = query ? `/akousmata/records?text=${encodeURIComponent(query)}&limit=24` : "/akousmata/records?limit=24";
1300+
const result = await fetchJson(url);
1301+
const records = result.records || [];
1302+
akousmataUi.note.textContent = records.length ? `${records.length}` : "";
1303+
if (!records.length) {
1304+
akousmataUi.list.innerHTML = `<p class="empty-note">${query ? "No shared memories match." : "The shared library is empty."}</p>`;
1305+
return;
1306+
}
1307+
akousmataUi.list.innerHTML = "";
1308+
records.forEach((record) => {
1309+
const row = document.createElement("div");
1310+
row.className = "row-item";
1311+
row.title = record.akousma_id;
1312+
const title = document.createElement("span");
1313+
title.className = "ri-title";
1314+
title.textContent = record.summary || record.akousma_id;
1315+
const meta = document.createElement("span");
1316+
meta.className = "ri-meta";
1317+
meta.textContent = [record.originating_app, (record.created_at || "").slice(0, 10)].filter(Boolean).join(" · ");
1318+
row.append(title, meta);
1319+
row.addEventListener("click", () => openAkousma(record.akousma_id));
1320+
akousmataUi.list.appendChild(row);
1321+
});
1322+
} catch (_) {
1323+
akousmataUi.list.innerHTML = `<p class="empty-note">Shared akousmata unavailable (py-akousma not installed?).</p>`;
1324+
}
1325+
}
1326+
1327+
async function openAkousma(akousmaId) {
1328+
try {
1329+
const data = await fetchJson(`/akousmata/records/${encodeURIComponent(akousmaId)}`);
1330+
const record = data.record;
1331+
akousmataUi.title.textContent = data.summary || akousmaId;
1332+
const rows = [];
1333+
const provenance = record.provenance || {};
1334+
rows.push(`<p class="empty-note" style="margin-top:0">${escapeHtml(akousmaId)} · ${escapeHtml(provenance.originating_app || "?")} · ${escapeHtml(provenance.origin || "?")} · ${escapeHtml((record.created_at || "").slice(0, 16).replace("T", " "))}</p>`);
1335+
if (data.audio_available) rows.push(`<audio controls style="width:100%" src="/akousmata/audio/${encodeURIComponent(akousmaId)}"></audio>`);
1336+
const listening = record.listening || {};
1337+
for (const namespace of Object.keys(listening).sort()) {
1338+
const entry = listening[namespace];
1339+
if (typeof entry !== "object" || entry === null) continue;
1340+
const payload = entry.payload && typeof entry.payload === "object" ? entry.payload : entry;
1341+
const text = entry.summary || payload.caption || payload.summary || payload.main_reading || payload.notes || "";
1342+
rows.push(`<p><strong class="ri-meta">${escapeHtml(namespace)}${entry.contract ? ` · ${escapeHtml(entry.contract)}` : ""}</strong><br>${escapeHtml(String(text).slice(0, 400)) || "<em>structured payload</em>"}</p>`);
1343+
}
1344+
const link = (ref) => `<a href="#" data-akousma="${escapeHtml(ref.akousma_id)}" class="${ref.missing ? "ri-meta" : ""}">${escapeHtml(ref.summary || ref.akousma_id)}</a>`;
1345+
if (data.parents.length) rows.push(`<p><strong class="ri-meta">made from</strong><br>${data.parents.map(link).join("<br>")}</p>`);
1346+
if (data.children.length) rows.push(`<p><strong class="ri-meta">became</strong><br>${data.children.map(link).join("<br>")}</p>`);
1347+
if (data.related.length) {
1348+
rows.push(`<p><strong class="ri-meta">kinship</strong><br>${data.related.map((item) => `${escapeHtml((item.type || "").replaceAll("_", " "))} ${item.direction === "incoming" ? "⭠" : "⭢"} ${link(item)}`).join("<br>")}</p>`);
1349+
}
1350+
if ((record.tags || []).length) rows.push(`<p class="ri-meta">#${record.tags.map(escapeHtml).join(" #")}</p>`);
1351+
rows.push(
1352+
`<p>` +
1353+
["sound", "prompt", "lineage"].map((mode) => `<button class="pill-button small" data-germ-mode="${mode}" data-germ-id="${escapeHtml(akousmaId)}">germ: ${mode}</button>`).join(" ") +
1354+
`</p>`,
1355+
);
1356+
akousmataUi.detail.innerHTML = rows.join("");
1357+
akousmataUi.detail.querySelectorAll("a[data-akousma]").forEach((anchor) => {
1358+
anchor.addEventListener("click", (event) => {
1359+
event.preventDefault();
1360+
openAkousma(anchor.dataset.akousma);
1361+
});
1362+
});
1363+
akousmataUi.detail.querySelectorAll("button[data-germ-mode]").forEach((button) => {
1364+
button.addEventListener("click", async () => {
1365+
try {
1366+
const data = await fetchJson(`/germ/link?akousma_id=${encodeURIComponent(button.dataset.germId)}&mode=${button.dataset.germMode}`);
1367+
window.open(data.germ_url, "_blank");
1368+
} catch (error) {
1369+
button.textContent = "germ unavailable";
1370+
}
1371+
});
1372+
});
1373+
if (typeof akousmataUi.modal.showModal === "function" && !akousmataUi.modal.open) akousmataUi.modal.showModal();
1374+
} catch (error) {
1375+
akousmataUi.list.insertAdjacentHTML("afterbegin", `<p class="empty-note">${escapeHtml(error.message)}</p>`);
1376+
}
1377+
}
1378+
1379+
if (akousmataUi.go) {
1380+
akousmataUi.go.addEventListener("click", () => refreshAkousmata(akousmataUi.search.value.trim() || undefined));
1381+
akousmataUi.search.addEventListener("keydown", (event) => {
1382+
if (event.key === "Enter") refreshAkousmata(akousmataUi.search.value.trim() || undefined);
1383+
});
1384+
akousmataUi.modal?.querySelector("[data-close]")?.addEventListener("click", () => akousmataUi.modal.close());
1385+
}
1386+
12841387
/* ────────────────────────────── boot ────────────────────────────── */
12851388

12861389
refreshHealth().finally(() => {
12871390
refreshHistory();
12881391
});
12891392
loadManifest();
12901393
refreshMemory();
1394+
refreshAkousmata();
12911395
refreshMicDevices(false); // load input devices by default, no permission prompt
12921396
connectStream();
12931397
setInterval(refreshHealth, 20000);

oida/static/index.html

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,15 @@
6464
</div>
6565
<div class="memory-list" id="memoryList"></div>
6666
</section>
67+
<section class="side-section">
68+
<div class="side-head"><h3>Akousmata</h3><span class="fold-note" id="akousmataNote"></span></div>
69+
<div class="memory-search">
70+
<input type="text" id="akousmataSearch" placeholder="the shared library…" />
71+
<button class="pill-button small icon-only" id="akousmataGo" aria-label="Search the shared akousmata" title="Search the shared akousmata">
72+
<svg class="ci" viewBox="0 0 24 24" width="15" height="15"><circle cx="11" cy="11" r="6.5"/><path d="m20 20-4.4-4.4"/></svg></button>
73+
</div>
74+
<div class="memory-list" id="akousmataList"></div>
75+
</section>
6776
</div>
6877
</aside>
6978

@@ -147,6 +156,12 @@
147156

148157
<input type="file" id="fileInput" accept="audio/*,video/*" hidden />
149158

159+
<!-- ────────────── Akousmata detail modal ───────────── -->
160+
<dialog class="modal" id="akousmataModal" aria-label="Akousmata memory">
161+
<div class="modal-head"><h3 id="akousmataTitle">Memory</h3><button class="modal-close" data-close aria-label="Close">×</button></div>
162+
<div class="modal-content"><div id="akousmataDetail"></div></div>
163+
</dialog>
164+
150165
<!-- ────────────── Skill / Engine / Path / Wiki modals ──────────── -->
151166
<dialog class="modal" id="skillModal" aria-label="Skills">
152167
<div class="modal-head"><h3>Skills <span class="tab-note" id="skillNote"></span></h3><button class="modal-close" data-close aria-label="Close">×</button></div>

0 commit comments

Comments
 (0)