Compare commits

..

1 Commits

Author SHA1 Message Date
564f72b3d4 feat(mnemosyne): entity resolution lines (#1167)
Some checks failed
CI / test (pull_request) Failing after 9s
CI / validate (pull_request) Failing after 12s
Review Approval Gate / verify-review (pull_request) Failing after 3s
Draw translucent lines between related crystals in 3D:
- Same entity: thin blue line (opacity 0.35)
- Related entities: thin purple dashed line (opacity 0.25)
- LOD: lines hidden when camera > 50 units from midpoint
- Distance-based opacity fade
- Deduplication (lower ID → higher ID only)
- Cleanup on memory removal

Closes #1167
2026-04-10 23:46:53 +00:00
3 changed files with 95 additions and 117 deletions

View File

@@ -60,23 +60,6 @@ If the heartbeat is older than --stale-threshold seconds, the
mind is considered dead even if the process is still running
(e.g., hung on a blocking call).
KIMI HEARTBEAT
==============
The Kimi triage pipeline writes a cron heartbeat file after each run:
/var/run/bezalel/heartbeats/kimi-heartbeat.last
(fallback: ~/.bezalel/heartbeats/kimi-heartbeat.last)
{
"job": "kimi-heartbeat",
"timestamp": 1711843200.0,
"interval_seconds": 900,
"pid": 12345,
"status": "ok"
}
If the heartbeat is stale (>2x declared interval), the watchdog reports
a Kimi Heartbeat failure alongside the other checks.
ZERO DEPENDENCIES
=================
Pure stdlib. No pip installs. Same machine as the nexus.
@@ -121,10 +104,6 @@ DEFAULT_HEARTBEAT_PATH = Path.home() / ".nexus" / "heartbeat.json"
DEFAULT_STALE_THRESHOLD = 300 # 5 minutes without a heartbeat = dead
DEFAULT_INTERVAL = 60 # seconds between checks in watch mode
# Kimi Heartbeat — cron job heartbeat file written by the triage pipeline
KIMI_HEARTBEAT_JOB = "kimi-heartbeat"
KIMI_HEARTBEAT_STALE_MULTIPLIER = 2.0 # stale at 2x declared interval
GITEA_URL = os.environ.get("GITEA_URL", "https://forge.alexanderwhitestone.com")
GITEA_TOKEN = os.environ.get("GITEA_TOKEN", "")
GITEA_REPO = os.environ.get("NEXUS_REPO", "Timmy_Foundation/the-nexus")
@@ -366,93 +345,6 @@ def check_syntax_health() -> CheckResult:
)
def check_kimi_heartbeat(
job: str = KIMI_HEARTBEAT_JOB,
stale_multiplier: float = KIMI_HEARTBEAT_STALE_MULTIPLIER,
) -> CheckResult:
"""Check if the Kimi Heartbeat cron job is alive.
Reads the ``<job>.last`` file from the standard Bezalel heartbeat
directory (``/var/run/bezalel/heartbeats/`` or fallback
``~/.bezalel/heartbeats/``). The file is written atomically by the
cron_heartbeat module after each successful triage pipeline run.
A job is stale when:
``time.time() - timestamp > stale_multiplier * interval_seconds``
(same rule used by ``check_cron_heartbeats.py``).
"""
# Resolve heartbeat directory — same logic as cron_heartbeat._resolve
primary = Path("/var/run/bezalel/heartbeats")
fallback = Path.home() / ".bezalel" / "heartbeats"
env_dir = os.environ.get("BEZALEL_HEARTBEAT_DIR")
if env_dir:
hb_dir = Path(env_dir)
elif primary.exists():
hb_dir = primary
elif fallback.exists():
hb_dir = fallback
else:
return CheckResult(
name="Kimi Heartbeat",
healthy=False,
message="Heartbeat directory not found — no triage pipeline deployed yet",
details={"searched": [str(primary), str(fallback)]},
)
hb_file = hb_dir / f"{job}.last"
if not hb_file.exists():
return CheckResult(
name="Kimi Heartbeat",
healthy=False,
message=f"No heartbeat file at {hb_file} — Kimi triage pipeline has never reported",
details={"path": str(hb_file)},
)
try:
data = json.loads(hb_file.read_text())
except (json.JSONDecodeError, OSError) as e:
return CheckResult(
name="Kimi Heartbeat",
healthy=False,
message=f"Heartbeat file corrupt: {e}",
details={"path": str(hb_file), "error": str(e)},
)
timestamp = float(data.get("timestamp", 0))
interval = int(data.get("interval_seconds", 0))
raw_status = data.get("status", "unknown")
age = time.time() - timestamp
if interval <= 0:
# No declared interval — use raw timestamp age (30 min default)
interval = 1800
threshold = stale_multiplier * interval
is_stale = age > threshold
age_str = f"{int(age)}s" if age < 3600 else f"{int(age // 3600)}h {int((age % 3600) // 60)}m"
interval_str = f"{int(interval)}s" if interval < 3600 else f"{int(interval // 3600)}h {int((interval % 3600) // 60)}m"
if is_stale:
return CheckResult(
name="Kimi Heartbeat",
healthy=False,
message=(
f"Silent for {age_str} "
f"(threshold: {stale_multiplier}x {interval_str} = {int(threshold)}s). "
f"Status: {raw_status}"
),
details=data,
)
return CheckResult(
name="Kimi Heartbeat",
healthy=True,
message=f"Alive — last beat {age_str} ago (interval {interval_str}, status={raw_status})",
details=data,
)
# ── Gitea alerting ───────────────────────────────────────────────────
def _gitea_request(method: str, path: str, data: Optional[dict] = None) -> Any:
@@ -554,7 +446,6 @@ def run_health_checks(
check_mind_process(),
check_heartbeat(heartbeat_path, stale_threshold),
check_syntax_health(),
check_kimi_heartbeat(),
]
return HealthReport(timestamp=time.time(), checks=checks)
@@ -654,14 +545,6 @@ def main():
"--json", action="store_true", dest="output_json",
help="Output results as JSON (for integration with other tools)",
)
parser.add_argument(
"--kimi-job", default=KIMI_HEARTBEAT_JOB,
help=f"Kimi heartbeat job name (default: {KIMI_HEARTBEAT_JOB})",
)
parser.add_argument(
"--kimi-stale-multiplier", type=float, default=KIMI_HEARTBEAT_STALE_MULTIPLIER,
help=f"Kimi heartbeat staleness multiplier (default: {KIMI_HEARTBEAT_STALE_MULTIPLIER})",
)
args = parser.parse_args()

View File

@@ -133,6 +133,9 @@ const SpatialMemory = (() => {
let _regionMarkers = {};
let _memoryObjects = {};
let _connectionLines = [];
let _entityLines = []; // entity resolution lines (issue #1167)
let _camera = null; // set by setCamera() for LOD culling
const ENTITY_LOD_DIST = 50; // hide entity lines when camera > this from midpoint
let _initialized = false;
// ─── CRYSTAL GEOMETRY (persistent memories) ───────────
@@ -252,6 +255,10 @@ const SpatialMemory = (() => {
_drawConnections(mem.id, mem.connections);
}
if (mem.entity) {
_drawEntityLines(mem.id, mem);
}
_dirty = true;
saveToStorage();
console.info('[Mnemosyne] Spatial memory placed:', mem.id, 'in', region.label);
@@ -298,6 +305,77 @@ const SpatialMemory = (() => {
});
}
// ─── ENTITY RESOLUTION LINES (#1167) ──────────────────
// Draw lines between crystals that share an entity or are related entities.
// Same entity → thin blue line. Related entities → thin purple dashed line.
function _drawEntityLines(memId, mem) {
if (!mem.entity) return;
const src = _memoryObjects[memId];
if (!src) return;
Object.entries(_memoryObjects).forEach(([otherId, other]) => {
if (otherId === memId) return;
const otherData = other.data;
if (!otherData.entity) return;
let lineType = null;
if (otherData.entity === mem.entity) {
lineType = 'same_entity';
} else if (mem.related_entities && mem.related_entities.includes(otherData.entity)) {
lineType = 'related';
} else if (otherData.related_entities && otherData.related_entities.includes(mem.entity)) {
lineType = 'related';
}
if (!lineType) return;
// Deduplicate — only draw from lower ID to higher
if (memId > otherId) return;
const points = [src.mesh.position.clone(), other.mesh.position.clone()];
const geo = new THREE.BufferGeometry().setFromPoints(points);
let mat;
if (lineType === 'same_entity') {
mat = new THREE.LineBasicMaterial({ color: 0x4488ff, transparent: true, opacity: 0.35 });
} else {
mat = new THREE.LineDashedMaterial({ color: 0x9966ff, dashSize: 0.3, gapSize: 0.2, transparent: true, opacity: 0.25 });
const line = new THREE.Line(geo, mat);
line.computeLineDistances();
line.userData = { type: 'entity_line', from: memId, to: otherId, lineType };
_scene.add(line);
_entityLines.push(line);
return;
}
const line = new THREE.Line(geo, mat);
line.userData = { type: 'entity_line', from: memId, to: otherId, lineType };
_scene.add(line);
_entityLines.push(line);
});
}
function _updateEntityLines() {
if (!_camera) return;
const camPos = _camera.position;
_entityLines.forEach(line => {
// Compute midpoint of line
const posArr = line.geometry.attributes.position.array;
const mx = (posArr[0] + posArr[3]) / 2;
const my = (posArr[1] + posArr[4]) / 2;
const mz = (posArr[2] + posArr[5]) / 2;
const dist = camPos.distanceTo(new THREE.Vector3(mx, my, mz));
if (dist > ENTITY_LOD_DIST) {
line.visible = false;
} else {
line.visible = true;
// Fade based on distance
const fade = Math.max(0, 1 - (dist / ENTITY_LOD_DIST));
const baseOpacity = line.userData.lineType === 'same_entity' ? 0.35 : 0.25;
line.material.opacity = baseOpacity * fade;
}
});
}
// ─── REMOVE A MEMORY ─────────────────────────────────
function removeMemory(memId) {
const obj = _memoryObjects[memId];
@@ -317,6 +395,16 @@ const SpatialMemory = (() => {
}
}
for (let i = _entityLines.length - 1; i >= 0; i--) {
const line = _entityLines[i];
if (line.userData.from === memId || line.userData.to === memId) {
if (line.parent) line.parent.remove(line);
line.geometry.dispose();
line.material.dispose();
_entityLines.splice(i, 1);
}
}
delete _memoryObjects[memId];
_dirty = true;
saveToStorage();
@@ -342,6 +430,8 @@ const SpatialMemory = (() => {
}
});
_updateEntityLines();
Object.values(_regionMarkers).forEach(marker => {
if (marker.ring && marker.ring.material) {
marker.ring.material.opacity = 0.1 + Math.sin(now * 0.001) * 0.05;
@@ -652,6 +742,11 @@ const SpatialMemory = (() => {
return _selectedId;
}
// ─── CAMERA REFERENCE (for entity line LOD) ─────────
function setCamera(camera) {
_camera = camera;
}
return {
init, placeMemory, removeMemory, update,
getMemoryAtPosition, getRegionAtPosition, getMemoriesInRegion, getAllMemories,