horn-intent-engine / web /index.html
SNAPKITTYWEST's picture
October 2026 main drop: mirror from GitHub
d15fde8 verified
Raw History Blame Contribute Delete
15.8 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Horn Intent Engine — Demo</title>
<style>
:root {
--bg: #0c0d0f;
--paper: #eceae4;
--steel: #9aabba;
--steel-dim: #6a7a88;
--accent: #c8d4de;
--border: #2a2c30;
--success: #7dcea0;
--warn: #e8b86d;
--error: #e07a7a;
--mono: "SF Mono", "Cascadia Code", "Consolas", monospace;
--sans: system-ui, -apple-system, sans-serif;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--paper);
font-family: var(--sans);
font-size: 14px;
line-height: 1.5;
min-height: 100vh;
}
header {
padding: 1.5rem 2rem;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
}
h1 {
margin: 0;
font-weight: 500;
font-size: 1.25rem;
letter-spacing: -0.02em;
color: var(--accent);
}
h1 span { color: var(--steel); font-weight: 400; }
.license {
font-size: 0.75rem;
color: var(--steel-dim);
font-family: var(--mono);
}
main {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1px;
background: var(--border);
min-height: calc(100vh - 70px);
}
@media (max-width: 900px) {
main { grid-template-columns: 1fr; }
}
.panel {
background: var(--bg);
padding: 1.5rem;
overflow: auto;
}
.panel h2 {
margin: 0 0 1rem;
font-size: 0.85rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--steel);
}
textarea, input[type="text"] {
width: 100%;
background: #141618;
border: 1px solid var(--border);
color: var(--paper);
font-family: var(--mono);
font-size: 12.5px;
padding: 0.75rem;
border-radius: 4px;
resize: vertical;
}
textarea:focus, input:focus {
outline: none;
border-color: var(--steel);
}
button {
background: transparent;
border: 1px solid var(--steel);
color: var(--accent);
font-family: var(--sans);
font-size: 13px;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
margin-right: 0.5rem;
margin-top: 0.5rem;
}
button:hover {
background: rgba(154, 171, 186, 0.1);
}
button.primary {
background: var(--steel);
color: var(--bg);
border-color: var(--steel);
}
button.primary:hover {
background: var(--accent);
}
.results {
margin-top: 1rem;
font-family: var(--mono);
font-size: 12px;
background: #101214;
border: 1px solid var(--border);
border-radius: 4px;
padding: 1rem;
min-height: 120px;
white-space: pre-wrap;
color: var(--steel);
}
.results .ok { color: var(--success); }
.results .fail { color: var(--error); }
.results .bind { color: var(--warn); }
.facts-list, .rules-list {
font-family: var(--mono);
font-size: 12px;
color: var(--steel-dim);
margin: 0.5rem 0 1rem;
}
.facts-list div, .rules-list div {
padding: 0.25rem 0;
border-bottom: 1px solid #1a1c1f;
}
.tabs {
display: flex;
gap: 0;
margin-bottom: 1rem;
border-bottom: 1px solid var(--border);
}
.tab {
padding: 0.5rem 1rem;
background: none;
border: none;
border-bottom: 2px solid transparent;
color: var(--steel-dim);
cursor: pointer;
margin: 0;
border-radius: 0;
}
.tab.active {
color: var(--accent);
border-bottom-color: var(--steel);
}
.tab-content { display: none; }
.tab-content.active { display: block; }
.example-btn {
font-size: 12px;
padding: 0.3rem 0.6rem;
margin: 0.25rem 0.25rem 0 0;
}
footer {
padding: 1rem 2rem;
border-top: 1px solid var(--border);
font-size: 0.75rem;
color: var(--steel-dim);
text-align: center;
}
</style>
</head>
<body>
<header>
<h1>Horn Intent Engine <span>— interactive demo</span></h1>
<div class="license">AGPL-3.0</div>
</header>
<main>
<div class="panel">
<div class="tabs">
<button class="tab active" data-tab="horn">Horn Resolver</button>
<button class="tab" data-tab="intent">Intent Matching</button>
</div>
<div id="horn" class="tab-content active">
<h2>Facts</h2>
<div class="facts-list" id="factsList"></div>
<textarea id="factInput" rows="2" placeholder="item item_1&#10;color item_1 red&#10;stored_item item_1 storage_a&#10;location storage_a warehouse"></textarea>
<button onclick="addFacts()">Add Facts</button>
<button onclick="clearFacts()">Clear</button>
<h2 style="margin-top:1.5rem">Rules</h2>
<div class="rules-list" id="rulesList"></div>
<textarea id="ruleInput" rows="3" placeholder="in_warehouse ?X :- stored_item ?X ?S, location ?S warehouse"></textarea>
<button onclick="addRule()">Add Rule</button>
<button onclick="clearRules()">Clear</button>
<h2 style="margin-top:1.5rem">Query</h2>
<input type="text" id="queryInput" placeholder="in_warehouse ?X" value="in_warehouse ?X">
<div>
<button class="primary" onclick="runQuery()">Run Query</button>
<button class="example-btn" onclick="loadExample()">Load Warehouse Example</button>
</div>
<div class="results" id="hornResults">Results will appear here.</div>
</div>
<div id="intent" class="tab-content">
<h2>Utterance</h2>
<input type="text" id="utteranceInput" placeholder="Show me the inventory" value="Show me the inventory">
<div>
<button class="primary" onclick="resolveIntent()">Resolve</button>
<button class="example-btn" onclick="document.getElementById('utteranceInput').value='Go left'">Go left</button>
<button class="example-btn" onclick="document.getElementById('utteranceInput').value='Cancel'">Cancel</button>
<button class="example-btn" onclick="document.getElementById('utteranceInput').value='Find red items in the warehouse'">Find red…</button>
</div>
<div class="results" id="intentResults">Intent resolution will appear here.</div>
</div>
</div>
<div class="panel">
<h2>Proof / Substitution</h2>
<div class="results" id="proofView" style="min-height:300px">Select a query or intent to see bindings and proof steps.</div>
<h2 style="margin-top:1.5rem">About</h2>
<p style="color:var(--steel-dim);font-size:13px">
This is a pure-JavaScript reimplementation of the GDScript Horn engine for demonstration.
Facts are ground atoms. Rules are Horn clauses. Queries use depth-first backward chaining with unification.
Variables are prefixed with <code>?</code>.
</p>
<p style="color:var(--steel-dim);font-size:13px">
The original source lives in <code>nlp/*.gd</code> and runs inside Godot 4.
This web demo is provided under the same AGPL-3.0 license.
</p>
</div>
</main>
<footer>
Horn Intent Engine · SnapKitty Research Lab · AGPL-3.0
</footer>
<script>
// --- Horn Engine (JS port of the GDScript logic) ---
class HornEngine {
constructor() {
this.facts = [];
this.rules = [];
this.freshCounter = 0;
this.MAX_DEPTH = 64;
this.MAX_RESULTS = 50;
}
addFact(pred, args) {
this.facts.push({ pred, args: [...args] });
}
addRule(head, body) {
this.rules.push({ head, body });
}
isVariable(term) {
return typeof term === 'string' && term.startsWith('?');
}
walk(term, env) {
let current = term;
let steps = 0;
while (this.isVariable(current) && env.has(current)) {
current = env.get(current);
steps++;
if (steps > this.MAX_DEPTH) break;
}
return current;
}
unify(a, b, env) {
a = this.walk(a, env);
b = this.walk(b, env);
if (a === b) return env;
if (this.isVariable(a)) {
const next = new Map(env);
next.set(a, b);
return next;
}
if (this.isVariable(b)) {
const next = new Map(env);
next.set(b, a);
return next;
}
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return null;
let current = env;
for (let i = 0; i < a.length; i++) {
current = this.unify(a[i], b[i], current);
if (!current) return null;
}
return current;
}
return null;
}
freshen(value, mapping) {
if (this.isVariable(value)) {
if (!mapping.has(value)) {
this.freshCounter++;
mapping.set(value, '?_v' + this.freshCounter);
}
return mapping.get(value);
}
if (Array.isArray(value)) {
return value.map(v => this.freshen(v, mapping));
}
if (value && typeof value === 'object') {
const result = {};
for (const k of Object.keys(value)) {
result[k] = this.freshen(value[k], mapping);
}
return result;
}
return value;
}
solve(goals, env = new Map(), depth = 0) {
if (goals.length === 0) return [env];
if (depth >= this.MAX_DEPTH) return [];
const goal = goals[0];
const remaining = goals.slice(1);
const answers = [];
// Facts
for (const fact of this.facts) {
if (fact.pred !== goal.pred) continue;
const nextEnv = this.unify(goal.args, fact.args, env);
if (nextEnv) {
answers.push(...this.solve(remaining, nextEnv, depth + 1));
}
if (answers.length >= this.MAX_RESULTS) return answers.slice(0, this.MAX_RESULTS);
}
// Rules
for (const stored of this.rules) {
const mapping = new Map();
const rule = this.freshen(stored, mapping);
const nextEnv = this.unify(goal.args, rule.head.args, env);
if (!nextEnv) continue;
const newGoals = rule.body.map(g => ({ pred: g.pred, args: g.args })).concat(remaining);
answers.push(...this.solve(newGoals, nextEnv, depth + 1));
if (answers.length >= this.MAX_RESULTS) return answers.slice(0, this.MAX_RESULTS);
}
return answers;
}
query(pred, args) {
return this.solve([{ pred, args }]);
}
}
// --- Intent Engine (simplified) ---
const INTENTS = [
{ id: 'open_inventory', patterns: [/^open my inventory$/, /^show me the inventory$/, /^show inventory$/], handler: 'handle_open_inventory', priority: 100 },
{ id: 'move_left', patterns: [/^move left$/, /^go left$/, /^turn left$/], handler: 'handle_move_left', priority: 90 },
{ id: 'cancel_action', patterns: [/^cancel$/, /^cancel that action$/, /^stop what you are doing$/], handler: 'handle_cancel_action', priority: 110 },
{ id: 'find_red_in_warehouse', patterns: [/^find red items in the warehouse$/, /^show red items in warehouse$/, /^find red items in warehouse$/], handler: 'handle_find_red_in_warehouse', priority: 80 }
];
function resolveIntentText(utterance) {
const normalized = utterance.trim().toLowerCase();
const sorted = [...INTENTS].sort((a, b) => b.priority - a.priority);
for (const intent of sorted) {
for (const pat of intent.patterns) {
if (pat.test(normalized)) {
return { status: 'matched', intent: intent.id, handler: intent.handler, source: utterance };
}
}
}
return { status: 'unmatched', source: utterance };
}
// --- UI ---
const engine = new HornEngine();
function parseTerm(s) {
return s.trim();
}
function parseArgs(str) {
return str.trim().split(/\s+/).filter(Boolean).map(parseTerm);
}
function addFacts() {
const text = document.getElementById('factInput').value.trim();
if (!text) return;
const lines = text.split('\n').map(l => l.trim()).filter(Boolean);
for (const line of lines) {
const parts = line.split(/\s+/);
if (parts.length < 1) continue;
const pred = parts[0];
const args = parts.slice(1);
engine.addFact(pred, args);
}
document.getElementById('factInput').value = '';
renderFacts();
}
function clearFacts() {
engine.facts = [];
renderFacts();
}
function addRule() {
const text = document.getElementById('ruleInput').value.trim();
if (!text) return;
// Format: headPred ?A ?B :- bodyPred1 ?X ?Y, bodyPred2 ?Z
const m = text.match(/^(\S+)\s+(.*?)\s*:-\s*(.+)$/);
if (!m) {
alert('Rule format: headPred ?X :- bodyPred ?A ?B, otherPred ?C');
return;
}
const headPred = m[1];
const headArgs = parseArgs(m[2]);
const bodyParts = m[3].split(',').map(s => s.trim());
const body = bodyParts.map(bp => {
const p = bp.split(/\s+/);
return { pred: p[0], args: p.slice(1) };
});
engine.addRule({ pred: headPred, args: headArgs }, body);
document.getElementById('ruleInput').value = '';
renderRules();
}
function clearRules() {
engine.rules = [];
renderRules();
}
function renderFacts() {
const el = document.getElementById('factsList');
if (engine.facts.length === 0) {
el.innerHTML = '<div style="color:#555">(no facts)</div>';
return;
}
el.innerHTML = engine.facts.map(f =>
`<div>${f.pred}(${f.args.join(', ')})</div>`
).join('');
}
function renderRules() {
const el = document.getElementById('rulesList');
if (engine.rules.length === 0) {
el.innerHTML = '<div style="color:#555">(no rules)</div>';
return;
}
el.innerHTML = engine.rules.map(r => {
const head = `${r.head.pred}(${r.head.args.join(', ')})`;
const body = r.body.map(g => `${g.pred}(${g.args.join(', ')})`).join(', ');
return `<div>${head} :- ${body}</div>`;
}).join('');
}
function runQuery() {
const q = document.getElementById('queryInput').value.trim();
if (!q) return;
const parts = q.split(/\s+/);
const pred = parts[0];
const args = parts.slice(1);
const results = engine.query(pred, args);
const out = document.getElementById('hornResults');
const proof = document.getElementById('proofView');
if (results.length === 0) {
out.innerHTML = '<span class="fail">No solutions.</span>';
proof.textContent = 'No proof found.';
return;
}
let html = `<span class="ok">${results.length} solution(s)</span>\n\n`;
results.forEach((env, i) => {
html += `Solution ${i + 1}:\n`;
for (const [k, v] of env) {
if (k.startsWith('?')) {
html += ` <span class="bind">${k}</span> = ${v}\n`;
}
}
html += '\n';
});
out.innerHTML = html;
proof.textContent = JSON.stringify(
results.map(env => Object.fromEntries(env)),
null, 2
);
}
function loadExample() {
engine.facts = [];
engine.rules = [];
engine.addFact('item', ['item_1']);
engine.addFact('color', ['item_1', 'red']);
engine.addFact('stored_item', ['item_1', 'storage_a']);
engine.addFact('location', ['storage_a', 'warehouse']);
engine.addRule(
{ pred: 'in_warehouse', args: ['?X'] },
[
{ pred: 'stored_item', args: ['?X', '?S'] },
{ pred: 'location', args: ['?S', 'warehouse'] }
]
);
document.getElementById('queryInput').value = 'in_warehouse ?X';
renderFacts();
renderRules();
runQuery();
}
function resolveIntent() {
const text = document.getElementById('utteranceInput').value;
const result = resolveIntentText(text);
const out = document.getElementById('intentResults');
const proof = document.getElementById('proofView');
if (result.status === 'matched') {
out.innerHTML = `<span class="ok">matched</span>\nintent: ${result.intent}\nhandler: ${result.handler}`;
if (result.intent === 'find_red_in_warehouse') {
proof.textContent = 'This intent maps to Horn goals:\n[\n color(?X, red),\n in_warehouse(?X)\n]\n\nLoad the warehouse example on the other tab and run the multi-goal query.';
} else {
proof.textContent = JSON.stringify(result, null, 2);
}
} else {
out.innerHTML = '<span class="fail">unmatched</span>';
proof.textContent = 'No matching intent.';
}
}
// Tabs
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
tab.classList.add('active');
document.getElementById(tab.dataset.tab).classList.add('active');
});
});
renderFacts();
renderRules();
</script>
</body>
</html>