Compare commits

..

2 Commits

Author SHA1 Message Date
Alexander Whitestone
77e6d67365 feat: make GOFAI worker deterministic and plan-capable
Some checks failed
CI / test (pull_request) Failing after 9s
CI / validate (pull_request) Failing after 15s
Review Approval Gate / verify-review (pull_request) Successful in 4s
2026-04-12 20:18:57 -04:00
Alexander Whitestone
5295d0b0eb feat: wire GOFAI facts into live state and mainline rules
Some checks failed
CI / test (pull_request) Failing after 8s
CI / validate (pull_request) Failing after 13s
Review Approval Gate / verify-review (pull_request) Failing after 2s
2026-04-12 19:28:48 -04:00
3 changed files with 43 additions and 117 deletions

13
app.js
View File

@@ -108,8 +108,8 @@ class SymbolicEngine {
}
}
addRule(condition, action, description) {
this.rules.push({ condition, action, description });
addRule(condition, action, description, triggerFacts = []) {
this.rules.push({ condition, action, description, triggerFacts });
}
reason() {
@@ -404,6 +404,7 @@ class NeuroSymbolicBridge {
}
perceive(rawState) {
Object.entries(rawState).forEach(([key, value]) => this.engine.addFact(key, value));
const concepts = [];
if (rawState.stability < 0.4 && rawState.energy > 60) concepts.push('UNSTABLE_OSCILLATION');
if (rawState.energy < 30 && rawState.activePortals > 2) concepts.push('CRITICAL_DRAIN_PATTERN');
@@ -574,7 +575,6 @@ class PSELayer {
constructor() {
this.worker = new Worker('gofai_worker.js');
this.worker.onmessage = (e) => this.handleWorkerMessage(e);
this.pendingRequests = new Map();
}
handleWorkerMessage(e) {
@@ -621,6 +621,9 @@ function setupGOFAI() {
// Setup FSM
agentFSMs['timmy'] = new AgentFSM('timmy', 'IDLE');
agentFSMs['timmy'].addTransition('IDLE', 'ANALYZING', (facts) => facts.get('activePortals') > 0);
symbolicEngine.addRule((facts) => facts.get('UNSTABLE_OSCILLATION'), () => 'STABILIZE MATRIX', 'Unstable oscillation demands stabilization', ['UNSTABLE_OSCILLATION']);
symbolicEngine.addRule((facts) => facts.get('CRITICAL_DRAIN_PATTERN'), () => 'SHED PORTAL LOAD', 'Critical drain demands portal shedding', ['CRITICAL_DRAIN_PATTERN']);
// Setup Planner
symbolicPlanner.addAction('Stabilize Matrix', { energy: 50 }, { stability: 1.0 });
@@ -631,11 +634,13 @@ function updateGOFAI(delta, elapsed) {
// Simulate perception
neuroBridge.perceive({ stability: 0.3, energy: 80, activePortals: 1 });
agentFSMs['timmy']?.update(symbolicEngine.facts);
// Run reasoning
if (Math.floor(elapsed * 2) > Math.floor((elapsed - delta) * 2)) {
symbolicEngine.reason();
pseLayer.offloadReasoning(Array.from(symbolicEngine.facts.entries()), symbolicEngine.rules.map(r => ({ description: r.description })));
pseLayer.offloadReasoning(Array.from(symbolicEngine.facts.entries()), symbolicEngine.rules.map((r) => ({ description: r.description, triggerFacts: r.triggerFacts })));
pseLayer.offloadPlanning(Object.fromEntries(symbolicEngine.facts), { stability: 1.0 }, symbolicPlanner.actions);
document.getElementById("pse-task-count").innerText = parseInt(document.getElementById("pse-task-count").innerText) + 1;
metaLayer.reflect();

View File

@@ -1,30 +1,35 @@
const heuristic = (state, goal) => Object.keys(goal).reduce((h, key) => h + (state[key] === goal[key] ? 0 : Math.abs((state[key] || 0) - (goal[key] || 0))), 0), preconditionsMet = (state, preconditions = {}) => Object.entries(preconditions).every(([key, value]) => (typeof value === 'number' ? (state[key] || 0) >= value : state[key] === value));
const findPlan = (initialState, goalState, actions = []) => {
const openSet = [{ state: initialState, plan: [], g: 0, h: heuristic(initialState, goalState) }];
const visited = new Map([[JSON.stringify(initialState), 0]]);
while (openSet.length) {
openSet.sort((a, b) => (a.g + a.h) - (b.g + b.h));
const { state, plan, g } = openSet.shift();
if (heuristic(state, goalState) === 0) return plan;
actions.forEach((action) => {
if (!preconditionsMet(state, action.preconditions)) return;
const nextState = { ...state, ...(action.effects || {}) };
const key = JSON.stringify(nextState);
const nextG = g + 1;
if (!visited.has(key) || nextG < visited.get(key)) {
visited.set(key, nextG);
openSet.push({ state: nextState, plan: [...plan, action.name], g: nextG, h: heuristic(nextState, goalState) });
}
});
}
return [];
};
// ═══ GOFAI PARALLEL WORKER (PSE) ═══
self.onmessage = function(e) {
const { type, data } = e.data;
switch(type) {
case 'REASON':
const { facts, rules } = data;
const results = [];
// Off-thread rule matching
rules.forEach(rule => {
// Simulate heavy rule matching
if (Math.random() > 0.95) {
results.push({ rule: rule.description, outcome: 'OFF-THREAD MATCH' });
}
});
self.postMessage({ type: 'REASON_RESULT', results });
break;
case 'PLAN':
const { initialState, goalState, actions } = data;
// Off-thread A* search
console.log('[PSE] Starting off-thread A* search...');
// Simulate planning delay
const startTime = performance.now();
while(performance.now() - startTime < 50) {} // Artificial load
self.postMessage({ type: 'PLAN_RESULT', plan: ['Off-Thread Step 1', 'Off-Thread Step 2'] });
break;
if (type === 'REASON') {
const factMap = new Map(data.facts || []);
const results = (data.rules || []).filter((rule) => (rule.triggerFacts || []).every((fact) => factMap.get(fact))).map((rule) => ({ rule: rule.description, outcome: 'OFF-THREAD MATCH' }));
self.postMessage({ type: 'REASON_RESULT', results });
return;
}
if (type === 'PLAN') {
const plan = findPlan(data.initialState || {}, data.goalState || {}, data.actions || []);
self.postMessage({ type: 'PLAN_RESULT', plan });
}
};

View File

@@ -243,108 +243,24 @@ async def playback(log_path: Path, ws_url: str):
await ws.send(json.dumps(event))
async def inject_event(event_type: str, ws_url: str, **kwargs):
"""Inject a single Evennia event into the Nexus WS gateway. Dev/test use."""
from nexus.evennia_event_adapter import (
actor_located, command_issued, command_result,
room_snapshot, session_bound,
)
builders = {
"room_snapshot": lambda: room_snapshot(
kwargs.get("room_key", "Gate"),
kwargs.get("title", "Gate"),
kwargs.get("desc", "The entrance gate."),
exits=kwargs.get("exits"),
objects=kwargs.get("objects"),
),
"actor_located": lambda: actor_located(
kwargs.get("actor_id", "Timmy"),
kwargs.get("room_key", "Gate"),
kwargs.get("room_name"),
),
"command_result": lambda: command_result(
kwargs.get("session_id", "dev-inject"),
kwargs.get("actor_id", "Timmy"),
kwargs.get("command_text", "look"),
kwargs.get("output_text", "You see the Gate."),
success=kwargs.get("success", True),
),
"command_issued": lambda: command_issued(
kwargs.get("session_id", "dev-inject"),
kwargs.get("actor_id", "Timmy"),
kwargs.get("command_text", "look"),
),
"session_bound": lambda: session_bound(
kwargs.get("session_id", "dev-inject"),
kwargs.get("account", "Timmy"),
kwargs.get("character", "Timmy"),
),
}
if event_type not in builders:
print(f"[inject] Unknown event type: {event_type}", flush=True)
print(f"[inject] Available: {', '.join(builders)}", flush=True)
sys.exit(1)
event = builders[event_type]()
payload = json.dumps(event)
if websockets is None:
print(f"[inject] websockets not installed, printing event:\n{payload}", flush=True)
return
try:
async with websockets.connect(ws_url, open_timeout=5) as ws:
await ws.send(payload)
print(f"[inject] Sent {event_type} -> {ws_url}", flush=True)
print(f"[inject] Payload: {payload}", flush=True)
except Exception as e:
print(f"[inject] Failed to send to {ws_url}: {e}", flush=True)
sys.exit(1)
def main():
parser = argparse.ArgumentParser(description="Evennia -> Nexus WebSocket Bridge")
sub = parser.add_subparsers(dest="mode")
live = sub.add_parser("live", help="Live tail Evennia logs and stream to Nexus")
live.add_argument("--log-dir", default="/root/workspace/timmy-academy/server/logs", help="Evennia logs directory")
live.add_argument("--ws", default="ws://127.0.0.1:8765", help="Nexus WebSocket URL")
replay = sub.add_parser("playback", help="Replay a telemetry JSONL file")
replay.add_argument("log_path", help="Path to Evennia telemetry JSONL")
replay.add_argument("--ws", default="ws://127.0.0.1:8765", help="Nexus WebSocket URL")
inject = sub.add_parser("inject", help="Inject a single Evennia event (dev/test)")
inject.add_argument("event_type", choices=["room_snapshot", "actor_located", "command_result", "command_issued", "session_bound"])
inject.add_argument("--ws", default="ws://127.0.0.1:8765", help="Nexus WebSocket URL")
inject.add_argument("--room-key", default="Gate", help="Room key (room_snapshot, actor_located)")
inject.add_argument("--title", default="Gate", help="Room title (room_snapshot)")
inject.add_argument("--desc", default="The entrance gate.", help="Room description (room_snapshot)")
inject.add_argument("--actor-id", default="Timmy", help="Actor ID")
inject.add_argument("--command-text", default="look", help="Command text (command_result, command_issued)")
inject.add_argument("--output-text", default="You see the Gate.", help="Command output (command_result)")
inject.add_argument("--session-id", default="dev-inject", help="Hermes session ID")
args = parser.parse_args()
if args.mode == "live":
asyncio.run(live_bridge(args.log_dir, args.ws))
elif args.mode == "playback":
asyncio.run(playback(Path(args.log_path).expanduser(), args.ws))
elif args.mode == "inject":
asyncio.run(inject_event(
args.event_type,
args.ws,
room_key=args.room_key,
title=args.title,
desc=args.desc,
actor_id=args.actor_id,
command_text=args.command_text,
output_text=args.output_text,
session_id=args.session_id,
))
else:
parser.print_help()