Compare commits
5 Commits
fix/1470-c
...
fix/1535
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6e206084b | ||
| 44bde9509f | |||
| b9bbcae298 | |||
|
|
b7bf532f4e | ||
|
|
95d485160a |
@@ -285,6 +285,49 @@ class AgentMemory:
|
||||
logger.warning(f"Failed to store memory: {e}")
|
||||
return None
|
||||
|
||||
def remember_alexander_request_response(
|
||||
self,
|
||||
*,
|
||||
request_text: str,
|
||||
response_text: str,
|
||||
requester: str = "Alexander Whitestone",
|
||||
source: str = "",
|
||||
metadata: Optional[dict] = None,
|
||||
) -> Optional[str]:
|
||||
"""Store an Alexander request + wizard response artifact in the sovereign room."""
|
||||
if not self._check_available():
|
||||
logger.warning("Cannot store Alexander artifact — MemPalace unavailable")
|
||||
return None
|
||||
|
||||
try:
|
||||
from nexus.mempalace.searcher import add_memory
|
||||
from nexus.mempalace.conversation_artifacts import build_request_response_artifact
|
||||
|
||||
artifact = build_request_response_artifact(
|
||||
requester=requester,
|
||||
responder=self.agent_name,
|
||||
request_text=request_text,
|
||||
response_text=response_text,
|
||||
source=source,
|
||||
)
|
||||
extra_metadata = dict(artifact.metadata)
|
||||
if metadata:
|
||||
extra_metadata.update(metadata)
|
||||
|
||||
doc_id = add_memory(
|
||||
text=artifact.text,
|
||||
room=artifact.room,
|
||||
wing=self.wing,
|
||||
palace_path=self.palace_path,
|
||||
source_file=source,
|
||||
extra_metadata=extra_metadata,
|
||||
)
|
||||
logger.debug("Stored Alexander request/response artifact in sovereign room")
|
||||
return doc_id
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to store Alexander artifact: {e}")
|
||||
return None
|
||||
|
||||
def write_diary(
|
||||
self,
|
||||
summary: Optional[str] = None,
|
||||
|
||||
@@ -395,6 +395,7 @@
|
||||
<div id="memory-connections-panel" class="memory-connections-panel" style="display:none;" aria-label="Memory Connections Panel"></div>
|
||||
|
||||
<script src="./boot.js"></script>
|
||||
<script src="./js/heartbeat.js"></script>
|
||||
<script src="./avatar-customization.js"></script>
|
||||
<script src="./lod-system.js"></script>
|
||||
<script>
|
||||
|
||||
293
js/heartbeat.js
Normal file
293
js/heartbeat.js
Normal file
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* WebSocket Heartbeat Client for The Nexus
|
||||
* Issue #1535: feat: WebSocket heartbeat with auto-reconnect from client
|
||||
*
|
||||
* Provides:
|
||||
* - Client sends heartbeat ping every 30s
|
||||
* - Server responds with pong + user count
|
||||
* - Client auto-reconnects on missed 2 heartbeats
|
||||
* - Reconnect preserves user position/identity
|
||||
*/
|
||||
|
||||
class NexusHeartbeat {
|
||||
constructor(options = {}) {
|
||||
this.heartbeatInterval = options.heartbeatInterval || 30000; // 30 seconds
|
||||
this.maxMissedHeartbeats = options.maxMissedHeartbeats || 2;
|
||||
this.reconnectDelay = options.reconnectDelay || 1000; // 1 second
|
||||
this.maxReconnectDelay = options.maxReconnectDelay || 30000; // 30 seconds
|
||||
|
||||
this.ws = null;
|
||||
this.heartbeatTimer = null;
|
||||
this.missedHeartbeats = 0;
|
||||
this.isConnected = false;
|
||||
this.userId = options.userId || this.generateUserId();
|
||||
this.position = options.position || { x: 0, y: 0, z: 0 };
|
||||
this.reconnectAttempts = 0;
|
||||
|
||||
// Callbacks
|
||||
this.onConnect = options.onConnect || (() => {});
|
||||
this.onDisconnect = options.onDisconnect || (() => {});
|
||||
this.onHeartbeat = options.onHeartbeat || (() => {});
|
||||
this.onUserCount = options.onUserCount || (() => {});
|
||||
this.onError = options.onError || console.error;
|
||||
|
||||
// Bind methods
|
||||
this.connect = this.connect.bind(this);
|
||||
this.disconnect = this.disconnect.bind(this);
|
||||
this.sendHeartbeat = this.sendHeartbeat.bind(this);
|
||||
this.handleMessage = this.handleMessage.bind(this);
|
||||
this.handleClose = this.handleClose.bind(this);
|
||||
this.handleError = this.handleError.bind(this);
|
||||
}
|
||||
|
||||
generateUserId() {
|
||||
return 'user_' + Math.random().toString(36).substr(2, 9);
|
||||
}
|
||||
|
||||
connect(url) {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
console.warn('Already connected');
|
||||
return;
|
||||
}
|
||||
|
||||
this.url = url;
|
||||
console.log(`Connecting to ${url}...`);
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(url);
|
||||
this.ws.onopen = this.handleOpen.bind(this);
|
||||
this.ws.onmessage = this.handleMessage;
|
||||
this.ws.onclose = this.handleClose;
|
||||
this.ws.onerror = this.handleError;
|
||||
} catch (error) {
|
||||
this.onError('Failed to create WebSocket:', error);
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
console.log('Disconnecting...');
|
||||
|
||||
// Stop heartbeat
|
||||
this.stopHeartbeat();
|
||||
|
||||
// Close WebSocket
|
||||
if (this.ws) {
|
||||
this.ws.onclose = null; // Prevent reconnect on manual disconnect
|
||||
this.ws.close(1000, 'Manual disconnect');
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
this.isConnected = false;
|
||||
this.missedHeartbeats = 0;
|
||||
this.reconnectAttempts = 0;
|
||||
}
|
||||
|
||||
handleOpen() {
|
||||
console.log('Connected to WebSocket');
|
||||
this.isConnected = true;
|
||||
this.missedHeartbeats = 0;
|
||||
this.reconnectAttempts = 0;
|
||||
|
||||
// Send reconnect message with user info
|
||||
this.sendReconnect();
|
||||
|
||||
// Start heartbeat
|
||||
this.startHeartbeat();
|
||||
|
||||
// Call connect callback
|
||||
this.onConnect();
|
||||
}
|
||||
|
||||
handleMessage(event) {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type === 'pong') {
|
||||
// Reset missed heartbeats
|
||||
this.missedHeartbeats = 0;
|
||||
|
||||
// Update user count
|
||||
if (data.user_count !== undefined) {
|
||||
this.onUserCount(data.user_count);
|
||||
}
|
||||
|
||||
// Call heartbeat callback
|
||||
this.onHeartbeat(data);
|
||||
|
||||
console.debug('Heartbeat pong received');
|
||||
} else if (data.type === 'health') {
|
||||
// Health check response
|
||||
console.debug('Health check:', data);
|
||||
} else {
|
||||
// Regular message
|
||||
console.debug('Message received:', data);
|
||||
}
|
||||
} catch (error) {
|
||||
// Not JSON or parse error
|
||||
console.debug('Non-JSON message received:', event.data);
|
||||
}
|
||||
}
|
||||
|
||||
handleClose(event) {
|
||||
console.log(`WebSocket closed: ${event.code} ${event.reason}`);
|
||||
this.isConnected = false;
|
||||
this.stopHeartbeat();
|
||||
|
||||
// Call disconnect callback
|
||||
this.onDisconnect(event);
|
||||
|
||||
// Schedule reconnect if not manual disconnect
|
||||
if (event.code !== 1000) {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
handleError(error) {
|
||||
this.onError('WebSocket error:', error);
|
||||
}
|
||||
|
||||
startHeartbeat() {
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer);
|
||||
}
|
||||
|
||||
console.log(`Starting heartbeat every ${this.heartbeatInterval / 1000}s`);
|
||||
|
||||
this.heartbeatTimer = setInterval(() => {
|
||||
this.sendHeartbeat();
|
||||
}, this.heartbeatInterval);
|
||||
|
||||
// Send initial heartbeat
|
||||
this.sendHeartbeat();
|
||||
}
|
||||
|
||||
stopHeartbeat() {
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer);
|
||||
this.heartbeatTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
sendHeartbeat() {
|
||||
if (!this.isConnected || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
console.warn('Cannot send heartbeat: not connected');
|
||||
return;
|
||||
}
|
||||
|
||||
const heartbeat = {
|
||||
type: 'heartbeat',
|
||||
timestamp: Date.now(),
|
||||
user_id: this.userId,
|
||||
position: this.position
|
||||
};
|
||||
|
||||
try {
|
||||
this.ws.send(JSON.stringify(heartbeat));
|
||||
console.debug('Heartbeat sent');
|
||||
|
||||
// Check for missed heartbeats
|
||||
this.missedHeartbeats++;
|
||||
if (this.missedHeartbeats > this.maxMissedHeartbeats) {
|
||||
console.warn(`Missed ${this.missedHeartbeats} heartbeats, reconnecting...`);
|
||||
this.ws.close(4000, 'Missed heartbeats');
|
||||
}
|
||||
} catch (error) {
|
||||
this.onError('Failed to send heartbeat:', error);
|
||||
this.ws.close(4001, 'Heartbeat send failed');
|
||||
}
|
||||
}
|
||||
|
||||
sendReconnect() {
|
||||
if (!this.isConnected || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
console.warn('Cannot send reconnect: not connected');
|
||||
return;
|
||||
}
|
||||
|
||||
const reconnect = {
|
||||
type: 'reconnect',
|
||||
timestamp: Date.now(),
|
||||
user_id: this.userId,
|
||||
position: this.position
|
||||
};
|
||||
|
||||
try {
|
||||
this.ws.send(JSON.stringify(reconnect));
|
||||
console.log('Reconnect message sent');
|
||||
} catch (error) {
|
||||
this.onError('Failed to send reconnect:', error);
|
||||
}
|
||||
}
|
||||
|
||||
scheduleReconnect() {
|
||||
if (this.reconnectAttempts >= 10) {
|
||||
console.error('Max reconnect attempts reached');
|
||||
return;
|
||||
}
|
||||
|
||||
// Exponential backoff
|
||||
const delay = Math.min(
|
||||
this.reconnectDelay * Math.pow(2, this.reconnectAttempts),
|
||||
this.maxReconnectDelay
|
||||
);
|
||||
|
||||
console.log(`Reconnecting in ${delay / 1000}s (attempt ${this.reconnectAttempts + 1})...`);
|
||||
|
||||
setTimeout(() => {
|
||||
this.reconnectAttempts++;
|
||||
this.connect(this.url);
|
||||
}, delay);
|
||||
}
|
||||
|
||||
updatePosition(x, y, z) {
|
||||
this.position = { x, y, z };
|
||||
|
||||
// Send position update if connected
|
||||
if (this.isConnected && this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
const update = {
|
||||
type: 'position',
|
||||
timestamp: Date.now(),
|
||||
user_id: this.userId,
|
||||
position: this.position
|
||||
};
|
||||
|
||||
try {
|
||||
this.ws.send(JSON.stringify(update));
|
||||
} catch (error) {
|
||||
console.warn('Failed to send position update:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getUserId() {
|
||||
return this.userId;
|
||||
}
|
||||
|
||||
getPosition() {
|
||||
return { ...this.position };
|
||||
}
|
||||
|
||||
isConnectionActive() {
|
||||
return this.isConnected && this.ws && this.ws.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
getStats() {
|
||||
return {
|
||||
connected: this.isConnected,
|
||||
userId: this.userId,
|
||||
position: this.position,
|
||||
missedHeartbeats: this.missedHeartbeats,
|
||||
reconnectAttempts: this.reconnectAttempts
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Export for use in other modules
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = NexusHeartbeat;
|
||||
}
|
||||
|
||||
// Global instance for browser use
|
||||
if (typeof window !== 'undefined') {
|
||||
window.NexusHeartbeat = NexusHeartbeat;
|
||||
}
|
||||
@@ -62,6 +62,15 @@ core_rooms:
|
||||
- proof-of-concept code snippets
|
||||
- benchmark data
|
||||
|
||||
- key: sovereign
|
||||
label: Sovereign
|
||||
purpose: Artifacts of Alexander Whitestone's requests, directives, and wizard responses
|
||||
examples:
|
||||
- dated request/response artifacts
|
||||
- conversation summaries with speaker tags
|
||||
- directive ledgers
|
||||
- response follow-through notes
|
||||
|
||||
optional_rooms:
|
||||
- key: evennia
|
||||
label: Evennia
|
||||
@@ -98,15 +107,6 @@ optional_rooms:
|
||||
purpose: Catch-all for artefacts not yet assigned to a named room
|
||||
wizards: ["*"]
|
||||
|
||||
- key: sovereign
|
||||
label: Sovereign
|
||||
purpose: Artifacts of Alexander Whitestone's requests, directives, and conversation history
|
||||
wizards: ["*"]
|
||||
conventions:
|
||||
naming: "YYYY-MM-DD_HHMMSS_<topic>.md"
|
||||
index: "INDEX.md"
|
||||
description: "Each artifact is a dated record of a request from Alexander and the wizard's response. The running INDEX.md provides a chronological catalog."
|
||||
|
||||
# Tunnel routing table
|
||||
# Defines which room pairs are connected across wizard wings.
|
||||
# A tunnel lets `recall <query> --fleet` search both wings at once.
|
||||
|
||||
@@ -14,6 +14,7 @@ from nexus.perception_adapter import (
|
||||
)
|
||||
from nexus.experience_store import ExperienceStore
|
||||
from nexus.trajectory_logger import TrajectoryLogger
|
||||
from nexus.chronicle import ChronicleWriter, AgentEvent, EventKind
|
||||
|
||||
try:
|
||||
from nexus.nexus_think import NexusMind
|
||||
@@ -29,4 +30,7 @@ __all__ = [
|
||||
"ExperienceStore",
|
||||
"TrajectoryLogger",
|
||||
"NexusMind",
|
||||
"ChronicleWriter",
|
||||
"AgentEvent",
|
||||
"EventKind",
|
||||
]
|
||||
|
||||
387
nexus/chronicle.py
Normal file
387
nexus/chronicle.py
Normal file
@@ -0,0 +1,387 @@
|
||||
"""
|
||||
Nexus Chronicle — Emergent Narrative from Agent Interactions
|
||||
|
||||
Watches the fleet's activity (dispatches, errors, recoveries,
|
||||
collaborations) and transforms raw event data into narrative prose.
|
||||
The system finds the dramatic arc in real work and produces a living
|
||||
chronicle. The story writes itself from the data.
|
||||
|
||||
Usage:
|
||||
from nexus.chronicle import ChronicleWriter, AgentEvent, EventKind
|
||||
|
||||
writer = ChronicleWriter()
|
||||
|
||||
writer.ingest(AgentEvent(kind=EventKind.DISPATCH, agent="claude", detail="took issue #42"))
|
||||
writer.ingest(AgentEvent(kind=EventKind.ERROR, agent="claude", detail="rate limit hit"))
|
||||
writer.ingest(AgentEvent(kind=EventKind.RECOVERY, agent="claude", detail="retried after backoff"))
|
||||
writer.ingest(AgentEvent(kind=EventKind.COMMIT, agent="claude", detail="feat: add narrative engine"))
|
||||
|
||||
prose = writer.render()
|
||||
print(prose)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Event model
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class EventKind(str, Enum):
|
||||
"""The kinds of agent events the chronicle recognises."""
|
||||
|
||||
DISPATCH = "dispatch" # agent claimed / was assigned work
|
||||
COMMIT = "commit" # agent produced a commit
|
||||
PUSH = "push" # agent pushed a branch
|
||||
PR_OPEN = "pr_open" # agent opened a pull request
|
||||
PR_MERGE = "pr_merge" # PR was merged
|
||||
ERROR = "error" # agent hit an error / exception
|
||||
RECOVERY = "recovery" # agent recovered from a failure
|
||||
ABANDON = "abandon" # agent abandoned a task (timeout / giving up)
|
||||
COLLABORATION = "collab" # two agents worked on the same thing
|
||||
HEARTBEAT = "heartbeat" # agent reported a heartbeat (alive signal)
|
||||
IDLE = "idle" # agent is waiting for work
|
||||
MILESTONE = "milestone" # notable achievement (e.g. 100th issue closed)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentEvent:
|
||||
"""One discrete thing that happened in the fleet."""
|
||||
|
||||
kind: EventKind
|
||||
agent: str # who did this (e.g. "claude", "mimo-v2-pro")
|
||||
detail: str = "" # free-text description
|
||||
timestamp: float = field(default_factory=time.time)
|
||||
metadata: dict = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"kind": self.kind.value,
|
||||
"agent": self.agent,
|
||||
"detail": self.detail,
|
||||
"timestamp": self.timestamp,
|
||||
"metadata": self.metadata,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "AgentEvent":
|
||||
return cls(
|
||||
kind=EventKind(data["kind"]),
|
||||
agent=data["agent"],
|
||||
detail=data.get("detail", ""),
|
||||
timestamp=data.get("timestamp", time.time()),
|
||||
metadata=data.get("metadata", {}),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Narrative templates — maps event kinds to prose fragments
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Each entry is a list so we can rotate through variants.
|
||||
_TEMPLATES: dict[EventKind, list[str]] = {
|
||||
EventKind.DISPATCH: [
|
||||
"{agent} stepped forward and claimed the work: {detail}.",
|
||||
"{agent} took on the challenge — {detail}.",
|
||||
"The task landed on {agent}'s desk: {detail}.",
|
||||
],
|
||||
EventKind.COMMIT: [
|
||||
'{agent} sealed a commit into the record: "{detail}".',
|
||||
'{agent} committed "{detail}" — progress crystallised.',
|
||||
"{agent} carved a new ring into the trunk: {detail}.",
|
||||
],
|
||||
EventKind.PUSH: [
|
||||
"{agent} pushed the work upstream.",
|
||||
"The branch rose into the forge — {agent}'s changes were live.",
|
||||
"{agent} sent their work into the wider current.",
|
||||
],
|
||||
EventKind.PR_OPEN: [
|
||||
"{agent} opened a pull request: {detail}.",
|
||||
"A proposal surfaced — {agent} asked the fleet to review {detail}.",
|
||||
"{agent} laid their work before the reviewers: {detail}.",
|
||||
],
|
||||
EventKind.PR_MERGE: [
|
||||
"{agent}'s branch folded into the whole: {detail}.",
|
||||
"Consensus reached — {agent}'s changes were merged: {detail}.",
|
||||
"{detail} joined the canon. {agent}'s contribution lives on.",
|
||||
],
|
||||
EventKind.ERROR: [
|
||||
"{agent} ran into an obstacle: {detail}.",
|
||||
"Trouble. {agent} encountered {detail} and had to pause.",
|
||||
"The path grew difficult — {agent} hit {detail}.",
|
||||
],
|
||||
EventKind.RECOVERY: [
|
||||
"{agent} regrouped and pressed on: {detail}.",
|
||||
"After the setback, {agent} found a way through: {detail}.",
|
||||
"{agent} recovered — {detail}.",
|
||||
],
|
||||
EventKind.ABANDON: [
|
||||
"{agent} released the task, unable to finish: {detail}.",
|
||||
"Sometimes wisdom is knowing when to let go. {agent} abandoned {detail}.",
|
||||
"{agent} stepped back from {detail}. Another will carry it forward.",
|
||||
],
|
||||
EventKind.COLLABORATION: [
|
||||
"{agent} and their peers converged on the same problem: {detail}.",
|
||||
"Two minds touched the same work — {agent} in collaboration: {detail}.",
|
||||
"The fleet coordinated — {agent} joined the effort on {detail}.",
|
||||
],
|
||||
EventKind.HEARTBEAT: [
|
||||
"{agent} checked in — still thinking, still present.",
|
||||
"A pulse from {agent}: the mind is alive.",
|
||||
"{agent} breathed through another cycle.",
|
||||
],
|
||||
EventKind.IDLE: [
|
||||
"{agent} rested, waiting for the next call.",
|
||||
"Quiet descended — {agent} held still between tasks.",
|
||||
"{agent} stood ready, watchful in the lull.",
|
||||
],
|
||||
EventKind.MILESTONE: [
|
||||
"A moment worth noting — {agent}: {detail}.",
|
||||
"The chronicle marks a milestone. {agent}: {detail}.",
|
||||
"History ticked over — {agent} reached {detail}.",
|
||||
],
|
||||
}
|
||||
|
||||
# Arc-level commentary triggered by sequences of events
|
||||
_ARC_TEMPLATES = {
|
||||
"struggle_and_recovery": (
|
||||
"There was a struggle here. {agent} hit trouble and came back stronger — "
|
||||
"the kind of arc that gives a chronicle its texture."
|
||||
),
|
||||
"silent_grind": (
|
||||
"No drama, just steady work. {agents} moved through the backlog with quiet persistence."
|
||||
),
|
||||
"abandon_then_retry": (
|
||||
"{agent} let go once. But the work called again, and this time it was answered."
|
||||
),
|
||||
"solo_sprint": (
|
||||
"{agent} ran the whole arc alone — dispatch to merge — without breaking stride."
|
||||
),
|
||||
"fleet_convergence": (
|
||||
"The fleet converged. Multiple agents touched the same thread and wove it tighter."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chronicle writer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ChronicleWriter:
|
||||
"""Accumulates agent events and renders them as narrative prose.
|
||||
|
||||
The writer keeps a running log of events. Call ``ingest()`` to add new
|
||||
events as they arrive, then ``render()`` to produce a prose snapshot of
|
||||
the current arc.
|
||||
|
||||
Events are also persisted to JSONL so the chronicle survives restarts.
|
||||
"""
|
||||
|
||||
def __init__(self, log_path: Optional[Path] = None):
|
||||
today = time.strftime("%Y-%m-%d")
|
||||
self.log_path = log_path or (
|
||||
Path.home() / ".nexus" / "chronicle" / f"chronicle_{today}.jsonl"
|
||||
)
|
||||
self.log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self._events: list[AgentEvent] = []
|
||||
self._template_counters: dict[EventKind, int] = {}
|
||||
|
||||
# Load any events already on disk for today
|
||||
self._load_existing()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def ingest(self, event: AgentEvent) -> None:
|
||||
"""Add an event to the chronicle and persist it to disk."""
|
||||
self._events.append(event)
|
||||
with open(self.log_path, "a") as f:
|
||||
f.write(json.dumps(event.to_dict()) + "\n")
|
||||
|
||||
def render(self, max_events: int = 50) -> str:
|
||||
"""Render the recent event stream as narrative prose.
|
||||
|
||||
Returns a multi-paragraph string suitable for display or logging.
|
||||
"""
|
||||
events = self._events[-max_events:]
|
||||
if not events:
|
||||
return "The chronicle is empty. No events have been recorded yet."
|
||||
|
||||
paragraphs: list[str] = []
|
||||
|
||||
# Opening line with timestamp range
|
||||
first_ts = time.strftime("%H:%M", time.localtime(events[0].timestamp))
|
||||
last_ts = time.strftime("%H:%M", time.localtime(events[-1].timestamp))
|
||||
paragraphs.append(
|
||||
f"The chronicle covers {len(events)} event(s) between {first_ts} and {last_ts}."
|
||||
)
|
||||
|
||||
# Event-by-event prose
|
||||
sentences: list[str] = []
|
||||
for evt in events:
|
||||
sentences.append(self._render_event(evt))
|
||||
paragraphs.append(" ".join(sentences))
|
||||
|
||||
# Arc-level commentary
|
||||
arc = self._detect_arc(events)
|
||||
if arc:
|
||||
paragraphs.append(arc)
|
||||
|
||||
return "\n\n".join(paragraphs)
|
||||
|
||||
def render_markdown(self, max_events: int = 50) -> str:
|
||||
"""Render as a Markdown document."""
|
||||
events = self._events[-max_events:]
|
||||
if not events:
|
||||
return "# Chronicle\n\n*No events recorded yet.*"
|
||||
|
||||
today = time.strftime("%Y-%m-%d")
|
||||
lines = [f"# Chronicle — {today}", ""]
|
||||
|
||||
for evt in events:
|
||||
ts = time.strftime("%H:%M:%S", time.localtime(evt.timestamp))
|
||||
prose = self._render_event(evt)
|
||||
lines.append(f"**{ts}** — {prose}")
|
||||
|
||||
arc = self._detect_arc(events)
|
||||
if arc:
|
||||
lines += ["", "---", "", f"*{arc}*"]
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def summary(self) -> dict:
|
||||
"""Return a structured summary of the current session."""
|
||||
agents: dict[str, dict] = {}
|
||||
kind_counts: dict[str, int] = {}
|
||||
|
||||
for evt in self._events:
|
||||
agents.setdefault(evt.agent, {"events": 0, "kinds": []})
|
||||
agents[evt.agent]["events"] += 1
|
||||
agents[evt.agent]["kinds"].append(evt.kind.value)
|
||||
kind_counts[evt.kind.value] = kind_counts.get(evt.kind.value, 0) + 1
|
||||
|
||||
return {
|
||||
"total_events": len(self._events),
|
||||
"agents": agents,
|
||||
"kind_counts": kind_counts,
|
||||
"log_path": str(self.log_path),
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _render_event(self, evt: AgentEvent) -> str:
|
||||
"""Turn a single event into a prose sentence."""
|
||||
templates = _TEMPLATES.get(evt.kind, ["{agent}: {detail}"])
|
||||
counter = self._template_counters.get(evt.kind, 0)
|
||||
template = templates[counter % len(templates)]
|
||||
self._template_counters[evt.kind] = counter + 1
|
||||
return template.format(agent=evt.agent, detail=evt.detail or evt.kind.value)
|
||||
|
||||
def _detect_arc(self, events: list[AgentEvent]) -> Optional[str]:
|
||||
"""Scan the event sequence for a recognisable dramatic arc."""
|
||||
if not events:
|
||||
return None
|
||||
|
||||
kinds = [e.kind for e in events]
|
||||
agents = list({e.agent for e in events})
|
||||
|
||||
# struggle → recovery
|
||||
if EventKind.ERROR in kinds and EventKind.RECOVERY in kinds:
|
||||
err_idx = kinds.index(EventKind.ERROR)
|
||||
rec_idx = kinds.index(EventKind.RECOVERY)
|
||||
if rec_idx > err_idx:
|
||||
agent = events[err_idx].agent
|
||||
return _ARC_TEMPLATES["struggle_and_recovery"].format(agent=agent)
|
||||
|
||||
# abandon → dispatch (retry): find first ABANDON, then any DISPATCH after it
|
||||
if EventKind.ABANDON in kinds and EventKind.DISPATCH in kinds:
|
||||
ab_idx = kinds.index(EventKind.ABANDON)
|
||||
retry_idx = next(
|
||||
(i for i, k in enumerate(kinds) if k == EventKind.DISPATCH and i > ab_idx),
|
||||
None,
|
||||
)
|
||||
if retry_idx is not None:
|
||||
agent = events[retry_idx].agent
|
||||
return _ARC_TEMPLATES["abandon_then_retry"].format(agent=agent)
|
||||
|
||||
# solo sprint: single agent goes dispatch→commit→pr_open→pr_merge
|
||||
solo_arc = {EventKind.DISPATCH, EventKind.COMMIT, EventKind.PR_OPEN, EventKind.PR_MERGE}
|
||||
if solo_arc.issubset(set(kinds)) and len(agents) == 1:
|
||||
return _ARC_TEMPLATES["solo_sprint"].format(agent=agents[0])
|
||||
|
||||
# fleet convergence: multiple agents, collaboration event
|
||||
if len(agents) > 1 and EventKind.COLLABORATION in kinds:
|
||||
return _ARC_TEMPLATES["fleet_convergence"]
|
||||
|
||||
# silent grind: only commits / heartbeats, no drama
|
||||
drama = {EventKind.ERROR, EventKind.ABANDON, EventKind.RECOVERY, EventKind.COLLABORATION}
|
||||
if not drama.intersection(set(kinds)) and EventKind.COMMIT in kinds:
|
||||
return _ARC_TEMPLATES["silent_grind"].format(agents=", ".join(agents))
|
||||
|
||||
return None
|
||||
|
||||
def _load_existing(self) -> None:
|
||||
"""Load events persisted from earlier in the same session."""
|
||||
if not self.log_path.exists():
|
||||
return
|
||||
with open(self.log_path) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
self._events.append(AgentEvent.from_dict(json.loads(line)))
|
||||
except (json.JSONDecodeError, KeyError, ValueError):
|
||||
continue # skip malformed lines
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Convenience: build events from common fleet signals
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def event_from_gitea_issue(payload: dict, agent: str) -> AgentEvent:
|
||||
"""Build a DISPATCH event from a Gitea issue assignment payload."""
|
||||
issue_num = payload.get("number", "?")
|
||||
title = payload.get("title", "")
|
||||
return AgentEvent(
|
||||
kind=EventKind.DISPATCH,
|
||||
agent=agent,
|
||||
detail=f"issue #{issue_num}: {title}",
|
||||
metadata={"issue_number": issue_num},
|
||||
)
|
||||
|
||||
|
||||
def event_from_heartbeat(hb: dict) -> AgentEvent:
|
||||
"""Build a HEARTBEAT event from a nexus heartbeat dict."""
|
||||
agent = hb.get("model", "unknown")
|
||||
status = hb.get("status", "thinking")
|
||||
cycle = hb.get("cycle", 0)
|
||||
return AgentEvent(
|
||||
kind=EventKind.HEARTBEAT,
|
||||
agent=agent,
|
||||
detail=f"cycle {cycle}, status={status}",
|
||||
metadata=hb,
|
||||
)
|
||||
|
||||
|
||||
def event_from_commit(commit: dict, agent: str) -> AgentEvent:
|
||||
"""Build a COMMIT event from a git commit dict."""
|
||||
message = commit.get("message", "").split("\n")[0] # subject line only
|
||||
sha = commit.get("sha", "")[:8]
|
||||
return AgentEvent(
|
||||
kind=EventKind.COMMIT,
|
||||
agent=agent,
|
||||
detail=message,
|
||||
metadata={"sha": sha},
|
||||
)
|
||||
@@ -13,6 +13,12 @@ from __future__ import annotations
|
||||
|
||||
from nexus.mempalace.config import MEMPALACE_PATH, FLEET_WING
|
||||
from nexus.mempalace.searcher import search_memories, add_memory, MemPalaceResult
|
||||
from nexus.mempalace.conversation_artifacts import (
|
||||
ConversationArtifact,
|
||||
build_request_response_artifact,
|
||||
extract_alexander_request_pairs,
|
||||
normalize_speaker,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"MEMPALACE_PATH",
|
||||
@@ -20,4 +26,8 @@ __all__ = [
|
||||
"search_memories",
|
||||
"add_memory",
|
||||
"MemPalaceResult",
|
||||
"ConversationArtifact",
|
||||
"build_request_response_artifact",
|
||||
"extract_alexander_request_pairs",
|
||||
"normalize_speaker",
|
||||
]
|
||||
|
||||
@@ -40,6 +40,7 @@ CORE_ROOMS: list[str] = [
|
||||
"nexus", # reports, docs, KT
|
||||
"issues", # tickets, backlog
|
||||
"experiments", # prototypes, spikes
|
||||
"sovereign", # Alexander request/response artifacts
|
||||
]
|
||||
|
||||
# ── ChromaDB collection name ──────────────────────────────────────────────────
|
||||
|
||||
122
nexus/mempalace/conversation_artifacts.py
Normal file
122
nexus/mempalace/conversation_artifacts.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""Helpers for preserving Alexander request/response artifacts in MemPalace.
|
||||
|
||||
This module provides a small, typed bridge between raw conversation turns and
|
||||
MemPalace drawers stored in the shared `sovereign` room. The goal is not to
|
||||
solve all future speaker-tagging needs at once; it gives the Nexus one
|
||||
canonical artifact shape that other miners and bridges can reuse.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Iterable
|
||||
|
||||
_ALEXANDER_ALIASES = {
|
||||
"alexander",
|
||||
"alexander whitestone",
|
||||
"rockachopa",
|
||||
"triptimmy",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConversationArtifact:
|
||||
requester: str
|
||||
responder: str
|
||||
request_text: str
|
||||
response_text: str
|
||||
room: str = "sovereign"
|
||||
timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"))
|
||||
metadata: dict = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
return (
|
||||
f"# Conversation Artifact\n\n"
|
||||
f"## Alexander Request\n{self.request_text.strip()}\n\n"
|
||||
f"## Wizard Response\n{self.response_text.strip()}\n"
|
||||
)
|
||||
|
||||
|
||||
def normalize_speaker(name: str | None) -> str:
|
||||
cleaned = " ".join((name or "").strip().lower().split())
|
||||
if cleaned in _ALEXANDER_ALIASES:
|
||||
return "alexander"
|
||||
return cleaned.replace(" ", "_") or "unknown"
|
||||
|
||||
|
||||
def build_request_response_artifact(
|
||||
*,
|
||||
requester: str,
|
||||
responder: str,
|
||||
request_text: str,
|
||||
response_text: str,
|
||||
source: str = "",
|
||||
timestamp: str | None = None,
|
||||
request_timestamp: str | None = None,
|
||||
response_timestamp: str | None = None,
|
||||
) -> ConversationArtifact:
|
||||
requester_slug = normalize_speaker(requester)
|
||||
responder_slug = normalize_speaker(responder)
|
||||
ts = timestamp or datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
metadata = {
|
||||
"artifact_type": "alexander_request_response",
|
||||
"requester": requester_slug,
|
||||
"responder": responder_slug,
|
||||
"speaker_tags": [f"speaker:{requester_slug}", f"speaker:{responder_slug}"],
|
||||
"source": source,
|
||||
"timestamp": ts,
|
||||
}
|
||||
if request_timestamp:
|
||||
metadata["request_timestamp"] = request_timestamp
|
||||
if response_timestamp:
|
||||
metadata["response_timestamp"] = response_timestamp
|
||||
return ConversationArtifact(
|
||||
requester=requester_slug,
|
||||
responder=responder_slug,
|
||||
request_text=request_text,
|
||||
response_text=response_text,
|
||||
timestamp=ts,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
|
||||
def extract_alexander_request_pairs(
|
||||
turns: Iterable[dict],
|
||||
*,
|
||||
responder: str,
|
||||
source: str = "",
|
||||
) -> list[ConversationArtifact]:
|
||||
responder_slug = normalize_speaker(responder)
|
||||
pending_request: dict | None = None
|
||||
artifacts: list[ConversationArtifact] = []
|
||||
|
||||
for turn in turns:
|
||||
speaker = normalize_speaker(
|
||||
turn.get("speaker") or turn.get("username") or turn.get("author") or turn.get("name")
|
||||
)
|
||||
text = (turn.get("text") or turn.get("content") or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
|
||||
if speaker == "alexander":
|
||||
pending_request = turn
|
||||
continue
|
||||
|
||||
if speaker == responder_slug and pending_request is not None:
|
||||
artifacts.append(
|
||||
build_request_response_artifact(
|
||||
requester="alexander",
|
||||
responder=responder_slug,
|
||||
request_text=(pending_request.get("text") or pending_request.get("content") or "").strip(),
|
||||
response_text=text,
|
||||
source=source,
|
||||
request_timestamp=pending_request.get("timestamp"),
|
||||
response_timestamp=turn.get("timestamp"),
|
||||
timestamp=turn.get("timestamp") or pending_request.get("timestamp"),
|
||||
)
|
||||
)
|
||||
pending_request = None
|
||||
|
||||
return artifacts
|
||||
@@ -1,47 +0,0 @@
|
||||
# timmy-config PR Backlog Triage Report
|
||||
|
||||
> Issue #1470 | the-nexus
|
||||
> Generated: 2026-04-16
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The issue was filed when timmy-config had 9 open PRs. Current state: **50 open PRs** — the backlog has grown 5.5x. This is the largest PR backlog in the entire Foundation.
|
||||
|
||||
## PR Breakdown by Category
|
||||
|
||||
| Category | Count | Action |
|
||||
|----------|-------|--------|
|
||||
| Training data (lyrics→scenes, prompts) | 18 | Merge if tests pass, close duplicates |
|
||||
| Adversary/jailbreak corpora | 6 | Merge after review |
|
||||
| Pipeline infrastructure | 8 | Review for conflicts, merge oldest first |
|
||||
| Config/ops tooling | 7 | Review individually |
|
||||
| Crisis response training | 4 | Merge (mission-critical) |
|
||||
| Provenance tracking | 3 | Consolidate, keep best |
|
||||
| Duplicates (same issue) | 4+ | Close all but best |
|
||||
|
||||
## Duplicate PRs to Close
|
||||
|
||||
| Issue | Duplicate PRs | Keep |
|
||||
|-------|--------------|------|
|
||||
| #598 | #765, #766 | Keep newer (#766) |
|
||||
| #691 | #751, #767, #760 | Consolidate |
|
||||
| #696 | #738, #743 | Keep newer (#743) |
|
||||
| #681 | #780, others | Already has PRs from prior sessions |
|
||||
|
||||
## Recommended Priority Order
|
||||
|
||||
1. **Merge crisis response PRs** (#597, #598) — mission-critical training data
|
||||
2. **Merge pipeline infra** (#621 orchestrator, #623 quality gate, #624 scheduler) — unblocks other work
|
||||
3. **Merge config tooling** (#686 drift detection, #662 cron audit)
|
||||
4. **Close duplicates** — save review time
|
||||
5. **Merge training data PRs** — batch merge if CI passes
|
||||
|
||||
## Prevention
|
||||
|
||||
- Add pre-flight PR check before burn sessions create new branches
|
||||
- Nightly triage cron to flag duplicate PRs
|
||||
- Label burn-created PRs for batch review
|
||||
|
||||
---
|
||||
|
||||
*Addressed by issue #1470*
|
||||
@@ -20,6 +20,7 @@ from agent.memory import (
|
||||
SessionTranscript,
|
||||
create_agent_memory,
|
||||
)
|
||||
from nexus.mempalace.conversation_artifacts import ConversationArtifact
|
||||
from agent.memory_hooks import MemoryHooks
|
||||
|
||||
|
||||
@@ -184,6 +185,24 @@ class TestAgentMemory:
|
||||
doc_id = mem.write_diary()
|
||||
assert doc_id is None # MemPalace unavailable
|
||||
|
||||
def test_remember_alexander_request_response_uses_sovereign_room(self):
|
||||
mem = AgentMemory(agent_name="allegro")
|
||||
mem._available = True
|
||||
with patch("nexus.mempalace.searcher.add_memory", return_value="doc-123") as add_memory:
|
||||
doc_id = mem.remember_alexander_request_response(
|
||||
request_text="Catalog my requests.",
|
||||
response_text="I will preserve them as artifacts.",
|
||||
requester="Alexander Whitestone",
|
||||
source="telegram:timmy-time",
|
||||
)
|
||||
|
||||
assert doc_id == "doc-123"
|
||||
kwargs = add_memory.call_args.kwargs
|
||||
assert kwargs["room"] == "sovereign"
|
||||
assert kwargs["wing"] == mem.wing
|
||||
assert kwargs["extra_metadata"]["artifact_type"] == "alexander_request_response"
|
||||
assert kwargs["extra_metadata"]["speaker_tags"] == ["speaker:alexander", "speaker:allegro"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MemoryHooks tests
|
||||
|
||||
211
tests/test_chronicle.py
Normal file
211
tests/test_chronicle.py
Normal file
@@ -0,0 +1,211 @@
|
||||
"""
|
||||
Tests for nexus.chronicle — emergent narrative from agent interactions.
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from nexus.chronicle import (
|
||||
AgentEvent,
|
||||
ChronicleWriter,
|
||||
EventKind,
|
||||
event_from_commit,
|
||||
event_from_gitea_issue,
|
||||
event_from_heartbeat,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AgentEvent
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAgentEvent:
|
||||
def test_roundtrip(self):
|
||||
evt = AgentEvent(
|
||||
kind=EventKind.DISPATCH,
|
||||
agent="claude",
|
||||
detail="took issue #42",
|
||||
)
|
||||
assert AgentEvent.from_dict(evt.to_dict()).kind == EventKind.DISPATCH
|
||||
assert AgentEvent.from_dict(evt.to_dict()).agent == "claude"
|
||||
assert AgentEvent.from_dict(evt.to_dict()).detail == "took issue #42"
|
||||
|
||||
def test_default_timestamp_is_recent(self):
|
||||
before = time.time()
|
||||
evt = AgentEvent(kind=EventKind.IDLE, agent="mimo")
|
||||
after = time.time()
|
||||
assert before <= evt.timestamp <= after
|
||||
|
||||
def test_all_event_kinds_are_valid_strings(self):
|
||||
for kind in EventKind:
|
||||
evt = AgentEvent(kind=kind, agent="test-agent")
|
||||
d = evt.to_dict()
|
||||
assert d["kind"] == kind.value
|
||||
restored = AgentEvent.from_dict(d)
|
||||
assert restored.kind == kind
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ChronicleWriter — basic ingestion and render
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestChronicleWriter:
|
||||
@pytest.fixture
|
||||
def writer(self, tmp_path):
|
||||
return ChronicleWriter(log_path=tmp_path / "chronicle.jsonl")
|
||||
|
||||
def test_empty_render(self, writer):
|
||||
text = writer.render()
|
||||
assert "empty" in text.lower()
|
||||
|
||||
def test_single_event_render(self, writer):
|
||||
writer.ingest(AgentEvent(kind=EventKind.DISPATCH, agent="claude", detail="issue #1"))
|
||||
text = writer.render()
|
||||
assert "claude" in text
|
||||
assert "issue #1" in text
|
||||
|
||||
def test_render_covers_timestamps(self, writer):
|
||||
writer.ingest(AgentEvent(kind=EventKind.DISPATCH, agent="a", detail="start"))
|
||||
writer.ingest(AgentEvent(kind=EventKind.COMMIT, agent="a", detail="done"))
|
||||
text = writer.render()
|
||||
assert "chronicle covers" in text.lower()
|
||||
|
||||
def test_events_persisted_to_disk(self, writer, tmp_path):
|
||||
writer.ingest(AgentEvent(kind=EventKind.COMMIT, agent="claude", detail="feat: x"))
|
||||
lines = (tmp_path / "chronicle.jsonl").read_text().strip().splitlines()
|
||||
assert len(lines) == 1
|
||||
data = json.loads(lines[0])
|
||||
assert data["kind"] == "commit"
|
||||
assert data["agent"] == "claude"
|
||||
|
||||
def test_load_existing_on_init(self, tmp_path):
|
||||
log = tmp_path / "chronicle.jsonl"
|
||||
evt = AgentEvent(kind=EventKind.PUSH, agent="mimo", detail="pushed branch")
|
||||
log.write_text(json.dumps(evt.to_dict()) + "\n")
|
||||
|
||||
writer2 = ChronicleWriter(log_path=log)
|
||||
assert len(writer2._events) == 1
|
||||
assert writer2._events[0].kind == EventKind.PUSH
|
||||
|
||||
def test_malformed_lines_are_skipped(self, tmp_path):
|
||||
log = tmp_path / "chronicle.jsonl"
|
||||
log.write_text("not-json\n{}\n")
|
||||
# Should not raise
|
||||
writer2 = ChronicleWriter(log_path=log)
|
||||
assert writer2._events == []
|
||||
|
||||
def test_template_rotation(self, writer):
|
||||
"""Consecutive events of the same kind use different templates."""
|
||||
sentences = set()
|
||||
for _ in range(3):
|
||||
writer.ingest(AgentEvent(kind=EventKind.HEARTBEAT, agent="claude"))
|
||||
text = writer.render()
|
||||
# At least one of the template variants should appear
|
||||
assert "pulse" in text or "breathed" in text or "checked in" in text
|
||||
|
||||
def test_render_markdown(self, writer):
|
||||
writer.ingest(AgentEvent(kind=EventKind.PR_OPEN, agent="claude", detail="PR #99"))
|
||||
md = writer.render_markdown()
|
||||
assert md.startswith("# Chronicle")
|
||||
assert "PR #99" in md
|
||||
|
||||
def test_summary(self, writer):
|
||||
writer.ingest(AgentEvent(kind=EventKind.DISPATCH, agent="claude", detail="x"))
|
||||
writer.ingest(AgentEvent(kind=EventKind.COMMIT, agent="claude", detail="y"))
|
||||
s = writer.summary()
|
||||
assert s["total_events"] == 2
|
||||
assert "claude" in s["agents"]
|
||||
assert s["kind_counts"]["dispatch"] == 1
|
||||
assert s["kind_counts"]["commit"] == 1
|
||||
|
||||
def test_max_events_limit(self, writer):
|
||||
for i in range(10):
|
||||
writer.ingest(AgentEvent(kind=EventKind.IDLE, agent="a", detail=str(i)))
|
||||
text = writer.render(max_events=3)
|
||||
# Only 3 events should appear in prose — check coverage header
|
||||
assert "3 event(s)" in text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Arc detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestArcDetection:
|
||||
@pytest.fixture
|
||||
def writer(self, tmp_path):
|
||||
return ChronicleWriter(log_path=tmp_path / "chronicle.jsonl")
|
||||
|
||||
def _ingest(self, writer, *kinds, agent="claude"):
|
||||
for k in kinds:
|
||||
writer.ingest(AgentEvent(kind=k, agent=agent, detail="x"))
|
||||
|
||||
def test_struggle_and_recovery_arc(self, writer):
|
||||
self._ingest(writer, EventKind.DISPATCH, EventKind.ERROR, EventKind.RECOVERY)
|
||||
text = writer.render()
|
||||
assert "struggle" in text.lower() or "trouble" in text.lower()
|
||||
|
||||
def test_no_arc_when_no_pattern(self, writer):
|
||||
self._ingest(writer, EventKind.IDLE)
|
||||
text = writer.render()
|
||||
# Should not include arc language (only 1 event, no pattern)
|
||||
assert "converged" not in text
|
||||
assert "struggle" not in text
|
||||
|
||||
def test_solo_sprint_arc(self, writer):
|
||||
self._ingest(
|
||||
writer,
|
||||
EventKind.DISPATCH,
|
||||
EventKind.COMMIT,
|
||||
EventKind.PR_OPEN,
|
||||
EventKind.PR_MERGE,
|
||||
)
|
||||
text = writer.render()
|
||||
assert "solo" in text.lower() or "alone" in text.lower()
|
||||
|
||||
def test_fleet_convergence_arc(self, writer, tmp_path):
|
||||
writer2 = ChronicleWriter(log_path=tmp_path / "chronicle.jsonl")
|
||||
writer2.ingest(AgentEvent(kind=EventKind.DISPATCH, agent="claude", detail="x"))
|
||||
writer2.ingest(AgentEvent(kind=EventKind.COLLABORATION, agent="mimo", detail="x"))
|
||||
writer2.ingest(AgentEvent(kind=EventKind.COMMIT, agent="claude", detail="x"))
|
||||
text = writer2.render()
|
||||
assert "converged" in text.lower() or "fleet" in text.lower()
|
||||
|
||||
def test_silent_grind_arc(self, writer):
|
||||
self._ingest(writer, EventKind.COMMIT, EventKind.COMMIT, EventKind.COMMIT)
|
||||
text = writer.render()
|
||||
assert "steady" in text.lower() or "quiet" in text.lower() or "grind" in text.lower()
|
||||
|
||||
def test_abandon_then_retry_arc(self, writer):
|
||||
self._ingest(writer, EventKind.DISPATCH, EventKind.ABANDON, EventKind.DISPATCH)
|
||||
text = writer.render()
|
||||
assert "let go" in text.lower() or "abandon" in text.lower() or "called again" in text.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Convenience constructors
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestConvenienceConstructors:
|
||||
def test_event_from_gitea_issue(self):
|
||||
payload = {"number": 42, "title": "feat: add narrative engine"}
|
||||
evt = event_from_gitea_issue(payload, agent="claude")
|
||||
assert evt.kind == EventKind.DISPATCH
|
||||
assert "42" in evt.detail
|
||||
assert evt.agent == "claude"
|
||||
|
||||
def test_event_from_heartbeat(self):
|
||||
hb = {"model": "claude-sonnet", "status": "thinking", "cycle": 7}
|
||||
evt = event_from_heartbeat(hb)
|
||||
assert evt.kind == EventKind.HEARTBEAT
|
||||
assert evt.agent == "claude-sonnet"
|
||||
assert "7" in evt.detail
|
||||
|
||||
def test_event_from_commit(self):
|
||||
commit = {"message": "feat: chronicle\n\nFixes #1607", "sha": "abc1234567"}
|
||||
evt = event_from_commit(commit, agent="claude")
|
||||
assert evt.kind == EventKind.COMMIT
|
||||
assert evt.detail == "feat: chronicle" # subject line only
|
||||
assert evt.metadata["sha"] == "abc12345"
|
||||
58
tests/test_conversation_artifacts.py
Normal file
58
tests/test_conversation_artifacts.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from nexus.mempalace.config import CORE_ROOMS
|
||||
from nexus.mempalace.conversation_artifacts import (
|
||||
ConversationArtifact,
|
||||
build_request_response_artifact,
|
||||
extract_alexander_request_pairs,
|
||||
normalize_speaker,
|
||||
)
|
||||
|
||||
|
||||
def test_sovereign_room_is_core_room() -> None:
|
||||
assert "sovereign" in CORE_ROOMS
|
||||
rooms_yaml = yaml.safe_load(Path("mempalace/rooms.yaml").read_text())
|
||||
assert any(room["key"] == "sovereign" for room in rooms_yaml["core_rooms"])
|
||||
|
||||
|
||||
def test_normalize_speaker_maps_alexander_variants() -> None:
|
||||
assert normalize_speaker("Alexander Whitestone") == "alexander"
|
||||
assert normalize_speaker("Rockachopa") == "alexander"
|
||||
assert normalize_speaker(" ALEXANDER ") == "alexander"
|
||||
assert normalize_speaker("Bezalel") == "bezalel"
|
||||
|
||||
|
||||
def test_build_request_response_artifact_creates_sovereign_metadata() -> None:
|
||||
artifact = build_request_response_artifact(
|
||||
requester="Alexander Whitestone",
|
||||
responder="Allegro",
|
||||
request_text="Please organize my conversation artifacts.",
|
||||
response_text="I will catalog them under a sovereign room.",
|
||||
source="telegram:timmy-time",
|
||||
timestamp="2026-04-16T01:30:00Z",
|
||||
)
|
||||
|
||||
assert isinstance(artifact, ConversationArtifact)
|
||||
assert artifact.room == "sovereign"
|
||||
assert artifact.metadata["speaker_tags"] == ["speaker:alexander", "speaker:allegro"]
|
||||
assert artifact.metadata["artifact_type"] == "alexander_request_response"
|
||||
assert artifact.metadata["responder"] == "allegro"
|
||||
assert "## Alexander Request" in artifact.text
|
||||
assert "## Wizard Response" in artifact.text
|
||||
|
||||
|
||||
def test_extract_alexander_request_pairs_finds_following_wizard_response() -> None:
|
||||
turns = [
|
||||
{"speaker": "Alexander Whitestone", "text": "Catalog my requests as artifacts.", "timestamp": "2026-04-16T01:00:00Z"},
|
||||
{"speaker": "Allegro", "text": "I'll build a sovereign room contract.", "timestamp": "2026-04-16T01:01:00Z"},
|
||||
{"speaker": "Alexander", "text": "Make sure my words are easy to recall.", "timestamp": "2026-04-16T01:02:00Z"},
|
||||
{"speaker": "Allegro", "text": "I will tag them with speaker metadata.", "timestamp": "2026-04-16T01:03:00Z"},
|
||||
]
|
||||
|
||||
artifacts = extract_alexander_request_pairs(turns, responder="Allegro", source="telegram")
|
||||
|
||||
assert len(artifacts) == 2
|
||||
assert artifacts[0].metadata["request_timestamp"] == "2026-04-16T01:00:00Z"
|
||||
assert artifacts[1].metadata["response_timestamp"] == "2026-04-16T01:03:00Z"
|
||||
Reference in New Issue
Block a user