@syntheos/broca 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin.mjs +6 -0
- package/package.json +21 -0
- package/src/ask.ts +171 -0
- package/src/db.ts +30 -0
- package/src/narrator.ts +167 -0
- package/src/server.ts +339 -0
- package/src/ui.ts +611 -0
package/bin.mjs
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { dirname, resolve } from "node:path";
|
|
5
|
+
const dir = dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
spawn("node", ["--experimental-strip-types", resolve(dir, "src/server.ts")], { stdio: "inherit" }).on("exit", process.exit);
|
package/package.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@syntheos/broca",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Agent action log and natural language narrator",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": { "node": ">=22.6.0" },
|
|
7
|
+
"bin": {
|
|
8
|
+
"syntheos-broca": "./bin.mjs"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"dev": "node --experimental-strip-types src/server.ts",
|
|
12
|
+
"start": "node --experimental-strip-types src/server.ts"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"libsql": "^0.5.0"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"typescript": "^5.8.0",
|
|
19
|
+
"@types/node": "^22.0.0"
|
|
20
|
+
}
|
|
21
|
+
}
|
package/src/ask.ts
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// ASK — natural language query over the agent OS stack
|
|
3
|
+
// Takes a question, uses LLM to pick the right service + endpoint,
|
|
4
|
+
// makes the call, then narrates the result back in plain English.
|
|
5
|
+
// ============================================================================
|
|
6
|
+
|
|
7
|
+
const LLM_URL = process.env.LLM_URL || "";
|
|
8
|
+
const LLM_API_KEY = process.env.LLM_API_KEY || "";
|
|
9
|
+
const LLM_MODEL = process.env.LLM_MODEL || "qwen2.5:14b";
|
|
10
|
+
|
|
11
|
+
// Per-service base URLs and keys (all optional — services are skipped if not configured)
|
|
12
|
+
const SERVICES: Record<string, { url: string; key: string }> = {
|
|
13
|
+
chiasm: { url: process.env.CHIASM_URL || "", key: process.env.CHIASM_API_KEY || "" },
|
|
14
|
+
engram: { url: process.env.ENGRAM_URL || "", key: process.env.ENGRAM_API_KEY || "" },
|
|
15
|
+
axon: { url: process.env.AXON_URL || "", key: process.env.AXON_API_KEY || "" },
|
|
16
|
+
loom: { url: process.env.LOOM_URL || "", key: process.env.LOOM_API_KEY || "" },
|
|
17
|
+
soma: { url: process.env.SOMA_URL || "", key: process.env.SOMA_API_KEY || "" },
|
|
18
|
+
thymus: { url: process.env.THYMUS_URL || "", key: process.env.THYMUS_API_KEY || "" },
|
|
19
|
+
broca: { url: process.env.BROCA_SELF_URL || `http://localhost:${process.env.PORT || 5100}`, key: process.env.BROCA_API_KEY || "" },
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const SERVICE_CATALOG = `
|
|
23
|
+
Available services and endpoints (only call what is configured):
|
|
24
|
+
|
|
25
|
+
chiasm (task tracker):
|
|
26
|
+
GET /tasks?status=active|blocked|blocked_on_human|completed|paused&agent=X&project=X&limit=N
|
|
27
|
+
GET /tasks/:id
|
|
28
|
+
GET /feed?limit=N&offset=N
|
|
29
|
+
|
|
30
|
+
engram (memory store):
|
|
31
|
+
POST /search body: {"query":"...","limit":N}
|
|
32
|
+
POST /context body: {"query":"...","budget":N}
|
|
33
|
+
|
|
34
|
+
axon (event bus):
|
|
35
|
+
GET /events?channel=X&limit=N&since=ISO
|
|
36
|
+
GET /channels
|
|
37
|
+
|
|
38
|
+
loom (workflow engine):
|
|
39
|
+
GET /runs?status=running|completed|failed|cancelled&limit=N
|
|
40
|
+
GET /runs/:id
|
|
41
|
+
GET /workflows
|
|
42
|
+
|
|
43
|
+
soma (agent registry):
|
|
44
|
+
GET /agents?status=online|offline&type=service|agent
|
|
45
|
+
GET /agents/:id
|
|
46
|
+
|
|
47
|
+
thymus (evaluations):
|
|
48
|
+
GET /evaluations?agent=X&limit=N
|
|
49
|
+
|
|
50
|
+
broca (action log):
|
|
51
|
+
GET /actions?agent=X&action=X&service=X&limit=N&since=ISO
|
|
52
|
+
GET /feed?limit=N
|
|
53
|
+
GET /stats
|
|
54
|
+
`.trim();
|
|
55
|
+
|
|
56
|
+
export interface AskPlan {
|
|
57
|
+
service: string;
|
|
58
|
+
method: "GET" | "POST";
|
|
59
|
+
path: string;
|
|
60
|
+
params?: Record<string, string | number>;
|
|
61
|
+
body?: Record<string, unknown>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface AskResult {
|
|
65
|
+
answer: string;
|
|
66
|
+
plan: AskPlan;
|
|
67
|
+
raw: unknown;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function callLLM(systemPrompt: string, userPrompt: string): Promise<string> {
|
|
71
|
+
if (!LLM_URL) throw new Error("LLM_URL not configured");
|
|
72
|
+
|
|
73
|
+
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
74
|
+
if (LLM_API_KEY) headers["Authorization"] = `Bearer ${LLM_API_KEY}`;
|
|
75
|
+
|
|
76
|
+
const isOllama = LLM_URL.includes("11434") || LLM_URL.includes("ollama");
|
|
77
|
+
const url = (isOllama && !LLM_URL.includes("/chat/completions"))
|
|
78
|
+
? LLM_URL.replace(/\/?$/, "") + "/v1/chat/completions"
|
|
79
|
+
: LLM_URL;
|
|
80
|
+
|
|
81
|
+
const res = await fetch(url, {
|
|
82
|
+
method: "POST",
|
|
83
|
+
headers,
|
|
84
|
+
body: JSON.stringify({
|
|
85
|
+
model: LLM_MODEL,
|
|
86
|
+
messages: [
|
|
87
|
+
{ role: "system", content: systemPrompt },
|
|
88
|
+
{ role: "user", content: userPrompt },
|
|
89
|
+
],
|
|
90
|
+
temperature: 0.2,
|
|
91
|
+
stream: false,
|
|
92
|
+
keep_alive: "10m",
|
|
93
|
+
}),
|
|
94
|
+
signal: AbortSignal.timeout(180000),
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
if (!res.ok) throw new Error(`LLM HTTP ${res.status}`);
|
|
98
|
+
const data = await res.json() as any;
|
|
99
|
+
return (data.choices?.[0]?.message?.content ?? data.result ?? data.text ?? "").trim();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function planQuery(question: string): Promise<AskPlan> {
|
|
103
|
+
const system = `You are a routing agent for an AI agent OS. Given a user question, decide which service API to call to answer it.
|
|
104
|
+
|
|
105
|
+
${SERVICE_CATALOG}
|
|
106
|
+
|
|
107
|
+
Respond with ONLY valid JSON matching this schema — no explanation, no markdown:
|
|
108
|
+
{"service":"<name>","method":"GET|POST","path":"/...","params":{},"body":null}
|
|
109
|
+
|
|
110
|
+
Rules:
|
|
111
|
+
- Use GET with params for filtering. Use POST with body only for engram /search or /context.
|
|
112
|
+
- For time-based questions ("today", "last hour", "recent") use limit=20 and omit since unless you know the exact time.
|
|
113
|
+
- If no service fits, use broca /feed.`;
|
|
114
|
+
|
|
115
|
+
const raw = await callLLM(system, question);
|
|
116
|
+
|
|
117
|
+
// Extract JSON even if model wraps it in markdown
|
|
118
|
+
const match = raw.match(/\{[\s\S]*\}/);
|
|
119
|
+
if (!match) throw new Error(`LLM returned non-JSON plan: ${raw.slice(0, 200)}`);
|
|
120
|
+
return JSON.parse(match[0]) as AskPlan;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function executeplan(plan: AskPlan): Promise<unknown> {
|
|
124
|
+
const svc = SERVICES[plan.service];
|
|
125
|
+
if (!svc?.url) throw new Error(`Service "${plan.service}" not configured`);
|
|
126
|
+
|
|
127
|
+
let url = svc.url.replace(/\/$/, "") + plan.path;
|
|
128
|
+
|
|
129
|
+
if (plan.method === "GET" && plan.params && Object.keys(plan.params).length > 0) {
|
|
130
|
+
const qs = new URLSearchParams(
|
|
131
|
+
Object.entries(plan.params).map(([k, v]) => [k, String(v)])
|
|
132
|
+
).toString();
|
|
133
|
+
url += "?" + qs;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
137
|
+
if (svc.key) headers["Authorization"] = `Bearer ${svc.key}`;
|
|
138
|
+
|
|
139
|
+
const res = await fetch(url, {
|
|
140
|
+
method: plan.method,
|
|
141
|
+
headers,
|
|
142
|
+
body: plan.method === "POST" && plan.body ? JSON.stringify(plan.body) : undefined,
|
|
143
|
+
signal: AbortSignal.timeout(15000),
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
if (!res.ok) throw new Error(`${plan.service} API returned HTTP ${res.status}`);
|
|
147
|
+
return res.json();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function narrateResult(question: string, plan: AskPlan, raw: unknown): Promise<string> {
|
|
151
|
+
const system = "You answer questions about an AI agent system. Be concise, direct, and use plain English. No JSON, no technical terms, no IDs.";
|
|
152
|
+
const user = `User asked: "${question}"
|
|
153
|
+
|
|
154
|
+
Data from ${plan.service} (${plan.method} ${plan.path}):
|
|
155
|
+
${JSON.stringify(raw, null, 2).slice(0, 2000)}
|
|
156
|
+
|
|
157
|
+
Answer the user's question directly in 1-3 sentences.`;
|
|
158
|
+
|
|
159
|
+
return callLLM(system, user);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function ask(question: string): Promise<AskResult> {
|
|
163
|
+
console.log("[ask] planning query:", question);
|
|
164
|
+
const plan = await planQuery(question);
|
|
165
|
+
console.log("[ask] plan:", JSON.stringify(plan));
|
|
166
|
+
const raw = await executeplan(plan);
|
|
167
|
+
console.log("[ask] got raw result, narrating...");
|
|
168
|
+
const answer = await narrateResult(question, plan, raw);
|
|
169
|
+
console.log("[ask] done");
|
|
170
|
+
return { answer, plan, raw };
|
|
171
|
+
}
|
package/src/db.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import Database from "libsql";
|
|
2
|
+
|
|
3
|
+
export function initDb(path: string): InstanceType<typeof Database> {
|
|
4
|
+
const db = new Database(path);
|
|
5
|
+
db.pragma("journal_mode = WAL");
|
|
6
|
+
db.pragma("busy_timeout = 5000");
|
|
7
|
+
db.pragma("synchronous = NORMAL");
|
|
8
|
+
|
|
9
|
+
db.exec(`
|
|
10
|
+
-- Every action any agent takes
|
|
11
|
+
CREATE TABLE IF NOT EXISTS actions (
|
|
12
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
13
|
+
agent TEXT NOT NULL,
|
|
14
|
+
service TEXT NOT NULL,
|
|
15
|
+
action TEXT NOT NULL,
|
|
16
|
+
payload TEXT NOT NULL DEFAULT '{}',
|
|
17
|
+
narrative TEXT,
|
|
18
|
+
axon_event_id INTEGER,
|
|
19
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
20
|
+
);
|
|
21
|
+
CREATE INDEX IF NOT EXISTS idx_actions_agent ON actions(agent, created_at DESC);
|
|
22
|
+
CREATE INDEX IF NOT EXISTS idx_actions_service ON actions(service, created_at DESC);
|
|
23
|
+
CREATE INDEX IF NOT EXISTS idx_actions_action ON actions(action, created_at DESC);
|
|
24
|
+
CREATE INDEX IF NOT EXISTS idx_actions_created ON actions(created_at DESC);
|
|
25
|
+
`);
|
|
26
|
+
|
|
27
|
+
return db;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type Db = InstanceType<typeof Database>;
|
package/src/narrator.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// NARRATOR — converts raw action payloads into plain English sentences
|
|
3
|
+
// Template-first, LLM fallback for unknowns
|
|
4
|
+
// ============================================================================
|
|
5
|
+
|
|
6
|
+
// LLM for fallback narration — supports Ollama, OpenAI-compat, or Engram-style endpoints
|
|
7
|
+
const LLM_URL = process.env.LLM_URL || process.env.ENGRAM_URL || "";
|
|
8
|
+
const LLM_API_KEY = process.env.LLM_API_KEY || process.env.ENGRAM_API_KEY || "";
|
|
9
|
+
const LLM_MODEL = process.env.LLM_MODEL || "qwen2.5:14b";
|
|
10
|
+
|
|
11
|
+
// Template registry: action -> (payload) -> string
|
|
12
|
+
type Template = (p: Record<string, unknown>) => string;
|
|
13
|
+
|
|
14
|
+
const templates: Record<string, Template> = {
|
|
15
|
+
// ---- Chiasm / tasks ----
|
|
16
|
+
"task.created": p => `${p.agent || p.source || "An agent"} started a new task: "${p.title}" in ${p.project}`,
|
|
17
|
+
"task.updated": p => `"${p.title}" status is now ${humanStatus(p.status)}${p.summary ? ` — ${p.summary}` : ""}`,
|
|
18
|
+
"task.completed": p => `"${p.title || p.task_title}" was completed${p.agent ? ` by ${p.agent}` : ""}`,
|
|
19
|
+
"task.blocked": p => `"${p.title}" is blocked${p.reason ? `: ${p.reason}` : ""}`,
|
|
20
|
+
"task.blocked_on_human": p => `"${p.title}" is waiting for human approval${p.summary ? `: ${p.summary}` : ""}`,
|
|
21
|
+
"task.feedback": p => `Human feedback on "${p.title}": "${p.feedback}"`,
|
|
22
|
+
"task.output": p => `Output submitted for "${p.title}"`,
|
|
23
|
+
"task.plan": p => `A plan was generated for "${p.title}"`,
|
|
24
|
+
|
|
25
|
+
// ---- Loom / workflows ----
|
|
26
|
+
"workflow.run.created": p => `${p.agent || "An agent"} started the "${p.workflow}" workflow`,
|
|
27
|
+
"workflow.run.completed": p => `The "${p.workflow}" workflow finished successfully`,
|
|
28
|
+
"workflow.run.failed": p => `The "${p.workflow}" workflow failed on step "${p.failed_step}"${p.error ? `: ${p.error}` : ""}`,
|
|
29
|
+
"workflow.run.cancelled": p => `The "${p.workflow}" workflow was cancelled`,
|
|
30
|
+
"workflow.step.started": p => `Step "${p.step}" started in the "${p.workflow}" workflow`,
|
|
31
|
+
"workflow.step.completed": p => `Step "${p.step}" finished in the "${p.workflow}" workflow`,
|
|
32
|
+
"workflow.step.failed": p => `Step "${p.step}" failed in the "${p.workflow}" workflow: ${p.error}`,
|
|
33
|
+
|
|
34
|
+
// ---- Soma / agents ----
|
|
35
|
+
"agent.registered": p => `${p.name} came online as a ${p.type}`,
|
|
36
|
+
"agent.deregistered": p => `${p.name} went offline`,
|
|
37
|
+
"agent.online": p => `${p.agent || p.name} is online`,
|
|
38
|
+
"agent.offline": p => `${p.agent || p.name} went offline`,
|
|
39
|
+
"agent.heartbeat": p => `${p.agent || p.name} checked in`,
|
|
40
|
+
"agent.error": p => `${p.agent || p.name} reported an error: ${p.error}`,
|
|
41
|
+
|
|
42
|
+
// ---- Engram / memory ----
|
|
43
|
+
"memory.stored": p => `${p.source || "An agent"} stored a memory${p.category ? ` (${p.category})` : ""}${p.content_preview ? `: "${String(p.content_preview).slice(0, 80)}${String(p.content_preview).length > 80 ? "…" : ""}"` : ""}`,
|
|
44
|
+
"memory.searched": p => `${p.agent || "An agent"} searched memory for "${p.query}"${p.results !== undefined ? ` — ${p.results} result${p.results === 1 ? "" : "s"}` : ""}`,
|
|
45
|
+
"memory.linked": p => `Two memories were linked together`,
|
|
46
|
+
"memory.forgotten": p => `A memory was removed`,
|
|
47
|
+
|
|
48
|
+
// ---- Thymus / evaluations ----
|
|
49
|
+
"evaluation.completed": p => {
|
|
50
|
+
const pct = p.overall_score !== undefined ? ` — scored ${Math.round(Number(p.overall_score) * 100)}%` : "";
|
|
51
|
+
return `${p.agent}'s work on "${p.subject}" was evaluated${pct} using the ${p.rubric} rubric`;
|
|
52
|
+
},
|
|
53
|
+
"metric.recorded": p => `${p.agent} recorded ${p.metric}: ${p.value}`,
|
|
54
|
+
|
|
55
|
+
// ---- Axon / system ----
|
|
56
|
+
"system.started": p => `${p.service || "A service"} started up`,
|
|
57
|
+
"system.stopped": p => `${p.service || "A service"} shut down`,
|
|
58
|
+
"deploy.started": p => `Deployment started${p.service ? ` for ${p.service}` : ""}`,
|
|
59
|
+
"deploy.succeeded": p => `${p.service || "Deployment"} deployed successfully`,
|
|
60
|
+
"deploy.failed": p => `Deployment failed${p.service ? ` for ${p.service}` : ""}${p.error ? `: ${p.error}` : ""}`,
|
|
61
|
+
"deploy.rolled_back": p => `${p.service || "Deployment"} was rolled back`,
|
|
62
|
+
"alert.triggered": p => `Alert triggered: ${p.message || p.name || "unknown"}`,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
function humanStatus(status: unknown): string {
|
|
66
|
+
const map: Record<string, string> = {
|
|
67
|
+
active: "active",
|
|
68
|
+
paused: "paused",
|
|
69
|
+
blocked: "blocked",
|
|
70
|
+
completed: "done",
|
|
71
|
+
blocked_on_human: "waiting for a human",
|
|
72
|
+
running: "running",
|
|
73
|
+
failed: "failed",
|
|
74
|
+
cancelled: "cancelled",
|
|
75
|
+
};
|
|
76
|
+
return map[String(status)] ?? String(status);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function narrateFromTemplate(action: string, payload: Record<string, unknown>): string | null {
|
|
80
|
+
const fn = templates[action];
|
|
81
|
+
if (!fn) return null;
|
|
82
|
+
try {
|
|
83
|
+
return fn(payload);
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function narrateWithLLM(
|
|
90
|
+
agent: string,
|
|
91
|
+
service: string,
|
|
92
|
+
action: string,
|
|
93
|
+
payload: Record<string, unknown>
|
|
94
|
+
): Promise<string> {
|
|
95
|
+
if (!LLM_URL) {
|
|
96
|
+
return `${agent} performed ${action} on ${service}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
100
|
+
if (LLM_API_KEY) headers["Authorization"] = `Bearer ${LLM_API_KEY}`;
|
|
101
|
+
|
|
102
|
+
const userPrompt = `Convert this agent action into a single plain English sentence a non-technical person would understand. Be concise and natural. No technical jargon, no IDs, no JSON terms.
|
|
103
|
+
|
|
104
|
+
Agent: ${agent}
|
|
105
|
+
Service: ${service}
|
|
106
|
+
Action: ${action}
|
|
107
|
+
Details: ${JSON.stringify(payload, null, 2)}
|
|
108
|
+
|
|
109
|
+
Respond with only the sentence, nothing else.`;
|
|
110
|
+
|
|
111
|
+
const system = "You translate technical agent actions into plain English. One sentence only.";
|
|
112
|
+
|
|
113
|
+
// Detect endpoint style
|
|
114
|
+
const isOllama = LLM_URL.includes("11434") || LLM_URL.includes("ollama");
|
|
115
|
+
const isOpenAICompat = LLM_URL.includes("/v1/chat") || LLM_URL.includes("/chat/completions");
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
let body: string;
|
|
119
|
+
let url = LLM_URL;
|
|
120
|
+
|
|
121
|
+
if (isOllama || isOpenAICompat) {
|
|
122
|
+
// OpenAI-compat format
|
|
123
|
+
if (isOllama && !LLM_URL.includes("/chat/completions")) {
|
|
124
|
+
url = LLM_URL.replace(/\/?$/, "") + "/v1/chat/completions";
|
|
125
|
+
}
|
|
126
|
+
body = JSON.stringify({
|
|
127
|
+
model: LLM_MODEL,
|
|
128
|
+
messages: [
|
|
129
|
+
{ role: "system", content: system },
|
|
130
|
+
{ role: "user", content: userPrompt },
|
|
131
|
+
],
|
|
132
|
+
temperature: 0.3,
|
|
133
|
+
stream: false,
|
|
134
|
+
keep_alive: "10m",
|
|
135
|
+
});
|
|
136
|
+
} else {
|
|
137
|
+
// Engram-style /llm endpoint
|
|
138
|
+
body = JSON.stringify({ prompt: userPrompt, system });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const res = await fetch(url, {
|
|
142
|
+
method: "POST",
|
|
143
|
+
headers,
|
|
144
|
+
body,
|
|
145
|
+
signal: AbortSignal.timeout(60000),
|
|
146
|
+
});
|
|
147
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
148
|
+
const data = await res.json() as any;
|
|
149
|
+
// OpenAI-compat response
|
|
150
|
+
const text = data.choices?.[0]?.message?.content
|
|
151
|
+
?? data.result ?? data.text ?? data.content;
|
|
152
|
+
return text?.trim() ?? `${agent} performed ${action} on ${service}`;
|
|
153
|
+
} catch {
|
|
154
|
+
return `${agent} performed ${action} on ${service}`;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export async function narrate(
|
|
159
|
+
agent: string,
|
|
160
|
+
service: string,
|
|
161
|
+
action: string,
|
|
162
|
+
payload: Record<string, unknown>
|
|
163
|
+
): Promise<string> {
|
|
164
|
+
const fromTemplate = narrateFromTemplate(action, payload);
|
|
165
|
+
if (fromTemplate) return fromTemplate;
|
|
166
|
+
return narrateWithLLM(agent, service, action, payload);
|
|
167
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import { createServer, type ServerResponse, type IncomingMessage } from "node:http";
|
|
2
|
+
import { initDb } from "./db.ts";
|
|
3
|
+
import { narrate, narrateFromTemplate } from "./narrator.ts";
|
|
4
|
+
import { ask } from "./ask.ts";
|
|
5
|
+
import { UI_HTML } from "./ui.ts";
|
|
6
|
+
|
|
7
|
+
const DB_PATH = process.env.DB_PATH ?? "./broca.db";
|
|
8
|
+
const HOST = process.env.HOST ?? "0.0.0.0";
|
|
9
|
+
const PORT = Number(process.env.PORT ?? 5000);
|
|
10
|
+
const AUTH_DISABLED = process.env.BROCA_AUTH === "disabled";
|
|
11
|
+
const BROCA_API_KEY = process.env.BROCA_API_KEY;
|
|
12
|
+
const CORS_ALLOW_ORIGIN = process.env.CORS_ALLOW_ORIGIN;
|
|
13
|
+
const BODY_MAX = 64 * 1024;
|
|
14
|
+
|
|
15
|
+
// Axon subscription: ingest events automatically
|
|
16
|
+
const AXON_URL = process.env.AXON_URL || "";
|
|
17
|
+
const AXON_API_KEY = process.env.AXON_API_KEY || "";
|
|
18
|
+
|
|
19
|
+
if (!BROCA_API_KEY && !AUTH_DISABLED) {
|
|
20
|
+
console.error("FATAL: BROCA_API_KEY not set. Set BROCA_AUTH=disabled to run without auth.");
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const db = initDb(DB_PATH);
|
|
25
|
+
|
|
26
|
+
// ============================================================================
|
|
27
|
+
// HELPERS
|
|
28
|
+
// ============================================================================
|
|
29
|
+
|
|
30
|
+
function json(res: ServerResponse, data: unknown, status = 200) {
|
|
31
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
32
|
+
res.end(JSON.stringify(data));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function err(res: ServerResponse, message: string, status = 400) {
|
|
36
|
+
json(res, { error: message }, status);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function applyCors(origin: string | undefined, res: ServerResponse) {
|
|
40
|
+
if (!CORS_ALLOW_ORIGIN) return;
|
|
41
|
+
if (CORS_ALLOW_ORIGIN === "*" || origin === CORS_ALLOW_ORIGIN) {
|
|
42
|
+
res.setHeader("Access-Control-Allow-Origin", CORS_ALLOW_ORIGIN === "*" ? "*" : origin ?? CORS_ALLOW_ORIGIN);
|
|
43
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
44
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
|
45
|
+
res.setHeader("Vary", "Origin");
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function authenticate(req: IncomingMessage): boolean {
|
|
50
|
+
if (AUTH_DISABLED) return true;
|
|
51
|
+
const auth = req.headers.authorization;
|
|
52
|
+
if (!auth?.startsWith("Bearer ")) return false;
|
|
53
|
+
return auth.slice(7) === BROCA_API_KEY;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function readBody(req: IncomingMessage): Promise<Record<string, unknown>> {
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
const chunks: Buffer[] = [];
|
|
59
|
+
let total = 0;
|
|
60
|
+
let settled = false;
|
|
61
|
+
const done = (fn: () => void) => { if (!settled) { settled = true; fn(); } };
|
|
62
|
+
req.on("data", (chunk: Buffer) => {
|
|
63
|
+
if (settled) return;
|
|
64
|
+
total += chunk.length;
|
|
65
|
+
if (total > BODY_MAX) { done(() => { req.resume(); reject(new Error("Body too large")); }); return; }
|
|
66
|
+
chunks.push(chunk);
|
|
67
|
+
});
|
|
68
|
+
req.on("end", () => done(() => {
|
|
69
|
+
if (chunks.length === 0) { resolve({}); return; }
|
|
70
|
+
try {
|
|
71
|
+
const parsed = JSON.parse(Buffer.concat(chunks).toString());
|
|
72
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { reject(new Error("Must be JSON object")); return; }
|
|
73
|
+
resolve(parsed);
|
|
74
|
+
} catch { reject(new Error("Invalid JSON")); }
|
|
75
|
+
}));
|
|
76
|
+
req.on("error", (e) => done(() => reject(e)));
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function bounded(v: string | null, fallback: number, min: number, max: number): number {
|
|
81
|
+
const n = Number.parseInt(v ?? "", 10);
|
|
82
|
+
return Number.isFinite(n) ? Math.min(Math.max(n, min), max) : fallback;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ============================================================================
|
|
86
|
+
// CORE LOGIC
|
|
87
|
+
// ============================================================================
|
|
88
|
+
|
|
89
|
+
async function logAction(
|
|
90
|
+
agent: string,
|
|
91
|
+
service: string,
|
|
92
|
+
action: string,
|
|
93
|
+
payload: Record<string, unknown>,
|
|
94
|
+
axonEventId?: number,
|
|
95
|
+
preNarrate = true,
|
|
96
|
+
): Promise<Record<string, unknown>> {
|
|
97
|
+
const narrative = preNarrate ? (narrateFromTemplate(action, payload) ?? null) : null;
|
|
98
|
+
|
|
99
|
+
const row = db.prepare(
|
|
100
|
+
"INSERT INTO actions (agent, service, action, payload, narrative, axon_event_id) VALUES (?, ?, ?, ?, ?, ?) RETURNING *"
|
|
101
|
+
).get(agent, service, action, JSON.stringify(payload), narrative, axonEventId ?? null) as Record<string, unknown>;
|
|
102
|
+
|
|
103
|
+
if (typeof row.payload === "string") {
|
|
104
|
+
try { row.payload = JSON.parse(row.payload as string); } catch { /* leave as-is */ }
|
|
105
|
+
}
|
|
106
|
+
return row;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function getActions(opts: {
|
|
110
|
+
agent?: string;
|
|
111
|
+
service?: string;
|
|
112
|
+
action?: string;
|
|
113
|
+
since?: string;
|
|
114
|
+
limit?: number;
|
|
115
|
+
offset?: number;
|
|
116
|
+
narrated_only?: boolean;
|
|
117
|
+
}) {
|
|
118
|
+
let query = "SELECT * FROM actions WHERE 1=1";
|
|
119
|
+
const params: Array<string | number> = [];
|
|
120
|
+
|
|
121
|
+
if (opts.agent) { query += " AND agent = ?"; params.push(opts.agent); }
|
|
122
|
+
if (opts.service) { query += " AND service = ?"; params.push(opts.service); }
|
|
123
|
+
if (opts.action) { query += " AND action = ?"; params.push(opts.action); }
|
|
124
|
+
if (opts.since) { query += " AND created_at >= ?"; params.push(opts.since); }
|
|
125
|
+
if (opts.narrated_only) { query += " AND narrative IS NOT NULL"; }
|
|
126
|
+
|
|
127
|
+
query += " ORDER BY id DESC LIMIT ? OFFSET ?";
|
|
128
|
+
params.push(opts.limit ?? 50, opts.offset ?? 0);
|
|
129
|
+
|
|
130
|
+
return (db.prepare(query).all(...params) as any[]).map(r => ({
|
|
131
|
+
...r,
|
|
132
|
+
payload: JSON.parse(r.payload),
|
|
133
|
+
}));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function getStats() {
|
|
137
|
+
const total = (db.prepare("SELECT COUNT(*) as c FROM actions").get() as any).c;
|
|
138
|
+
const narrated = (db.prepare("SELECT COUNT(*) as c FROM actions WHERE narrative IS NOT NULL").get() as any).c;
|
|
139
|
+
const by_service = db.prepare("SELECT service, COUNT(*) as count FROM actions GROUP BY service ORDER BY count DESC").all();
|
|
140
|
+
const by_agent = db.prepare("SELECT agent, COUNT(*) as count FROM actions GROUP BY agent ORDER BY count DESC").all();
|
|
141
|
+
const by_action = db.prepare("SELECT action, COUNT(*) as count FROM actions GROUP BY action ORDER BY count DESC").all();
|
|
142
|
+
return { total, narrated, by_service, by_agent, by_action };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ============================================================================
|
|
146
|
+
// AXON WEBHOOK INGESTION
|
|
147
|
+
// Subscribe to Axon and receive events as webhooks pointing back here
|
|
148
|
+
// ============================================================================
|
|
149
|
+
|
|
150
|
+
async function subscribeToAxon() {
|
|
151
|
+
if (!AXON_URL) return;
|
|
152
|
+
|
|
153
|
+
const selfUrl = process.env.SELF_URL || `http://localhost:${PORT}`;
|
|
154
|
+
const webhookUrl = `${selfUrl}/ingest`;
|
|
155
|
+
|
|
156
|
+
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
157
|
+
if (AXON_API_KEY) headers["Authorization"] = `Bearer ${AXON_API_KEY}`;
|
|
158
|
+
|
|
159
|
+
// Subscribe to all channels with wildcard
|
|
160
|
+
for (const channel of ["system", "memory", "tasks", "deploy", "alerts"]) {
|
|
161
|
+
try {
|
|
162
|
+
await fetch(`${AXON_URL}/subscribe`, {
|
|
163
|
+
method: "POST",
|
|
164
|
+
headers,
|
|
165
|
+
body: JSON.stringify({ agent: "broca", channel, webhook_url: webhookUrl }),
|
|
166
|
+
signal: AbortSignal.timeout(5000),
|
|
167
|
+
});
|
|
168
|
+
} catch { /* Axon may not be up yet */ }
|
|
169
|
+
}
|
|
170
|
+
console.log(`Subscribed to Axon at ${AXON_URL}`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ============================================================================
|
|
174
|
+
// HTTP SERVER
|
|
175
|
+
// ============================================================================
|
|
176
|
+
|
|
177
|
+
const server = createServer(async (req, res) => {
|
|
178
|
+
applyCors(req.headers.origin, res);
|
|
179
|
+
if (req.method === "OPTIONS") { res.writeHead(204); return res.end(); }
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
const url = new URL(req.url!, `http://${req.headers.host}`);
|
|
183
|
+
const path = url.pathname;
|
|
184
|
+
|
|
185
|
+
if (path === "/" && req.method === "GET") {
|
|
186
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
187
|
+
return res.end(UI_HTML);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (path === "/health" && req.method === "GET") {
|
|
191
|
+
return json(res, { status: "ok", version: "0.1.0", ...getStats() });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ---- INGEST from Axon webhook (no auth — Axon pushes here) ----
|
|
195
|
+
if (path === "/ingest" && req.method === "POST") {
|
|
196
|
+
const body = await readBody(req);
|
|
197
|
+
// Axon event shape: { id, channel, source, type, payload, created_at }
|
|
198
|
+
const { id: axonId, source, type, payload } = body as {
|
|
199
|
+
id?: number; channel?: string; source?: string; type?: string; payload?: Record<string, unknown>;
|
|
200
|
+
};
|
|
201
|
+
if (!source || !type) return err(res, "source and type required");
|
|
202
|
+
await logAction(source, source, type, payload ?? {}, axonId);
|
|
203
|
+
return json(res, { ok: true });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (!authenticate(req)) return err(res, "Unauthorized", 401);
|
|
207
|
+
|
|
208
|
+
// ---- LOG an action directly ----
|
|
209
|
+
if (path === "/actions" && req.method === "POST") {
|
|
210
|
+
const body = await readBody(req);
|
|
211
|
+
const { agent, service, action, payload } = body as {
|
|
212
|
+
agent?: string; service?: string; action?: string; payload?: Record<string, unknown>;
|
|
213
|
+
};
|
|
214
|
+
if (!agent || !service || !action) return err(res, "agent, service, and action required");
|
|
215
|
+
const result = await logAction(agent, service, action, payload ?? {});
|
|
216
|
+
return json(res, result, 201);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ---- QUERY actions ----
|
|
220
|
+
if (path === "/actions" && req.method === "GET") {
|
|
221
|
+
const actions = getActions({
|
|
222
|
+
agent: url.searchParams.get("agent") ?? undefined,
|
|
223
|
+
service: url.searchParams.get("service") ?? undefined,
|
|
224
|
+
action: url.searchParams.get("action") ?? undefined,
|
|
225
|
+
since: url.searchParams.get("since") ?? undefined,
|
|
226
|
+
limit: bounded(url.searchParams.get("limit"), 50, 1, 500),
|
|
227
|
+
offset: bounded(url.searchParams.get("offset"), 0, 0, 1e9),
|
|
228
|
+
narrated_only: url.searchParams.get("narrated_only") === "true",
|
|
229
|
+
});
|
|
230
|
+
return json(res, actions);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ---- GET single action ----
|
|
234
|
+
const actionMatch = path.match(/^\/actions\/(\d+)$/);
|
|
235
|
+
if (actionMatch && req.method === "GET") {
|
|
236
|
+
const row = db.prepare("SELECT * FROM actions WHERE id = ?").get(parseInt(actionMatch[1], 10)) as any;
|
|
237
|
+
if (!row) return err(res, "Action not found", 404);
|
|
238
|
+
return json(res, { ...row, payload: JSON.parse(row.payload) });
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ---- NARRATE a single action (LLM fallback if no template) ----
|
|
242
|
+
const narrateMatch = path.match(/^\/actions\/(\d+)\/narrate$/);
|
|
243
|
+
if (narrateMatch && req.method === "GET") {
|
|
244
|
+
const row = db.prepare("SELECT * FROM actions WHERE id = ?").get(parseInt(narrateMatch[1], 10)) as any;
|
|
245
|
+
if (!row) return err(res, "Action not found", 404);
|
|
246
|
+
|
|
247
|
+
const payload = JSON.parse(row.payload);
|
|
248
|
+
let narrative = row.narrative;
|
|
249
|
+
|
|
250
|
+
if (!narrative) {
|
|
251
|
+
narrative = await narrate(row.agent, row.service, row.action, payload);
|
|
252
|
+
db.prepare("UPDATE actions SET narrative = ? WHERE id = ?").run(narrative, row.id);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return json(res, { id: row.id, narrative, action: row.action, agent: row.agent, created_at: row.created_at });
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ---- FEED — human-readable activity feed ----
|
|
259
|
+
// Returns recent actions with narratives, auto-generates for any missing
|
|
260
|
+
if (path === "/feed" && req.method === "GET") {
|
|
261
|
+
const limit = bounded(url.searchParams.get("limit"), 20, 1, 100);
|
|
262
|
+
const offset = bounded(url.searchParams.get("offset"), 0, 0, 1e9);
|
|
263
|
+
const agent = url.searchParams.get("agent") ?? undefined;
|
|
264
|
+
const since = url.searchParams.get("since") ?? undefined;
|
|
265
|
+
|
|
266
|
+
const actions = getActions({ agent, since, limit, offset });
|
|
267
|
+
|
|
268
|
+
// Fill in missing narratives (template only, fast)
|
|
269
|
+
const feed = actions.map(a => {
|
|
270
|
+
const narrative = a.narrative ?? narrateFromTemplate(a.action, a.payload) ?? `${a.agent} performed ${a.action}`;
|
|
271
|
+
if (!a.narrative && narrative) {
|
|
272
|
+
db.prepare("UPDATE actions SET narrative = ? WHERE id = ?").run(narrative, a.id);
|
|
273
|
+
}
|
|
274
|
+
return {
|
|
275
|
+
id: a.id,
|
|
276
|
+
narrative,
|
|
277
|
+
agent: a.agent,
|
|
278
|
+
service: a.service,
|
|
279
|
+
action: a.action,
|
|
280
|
+
created_at: a.created_at,
|
|
281
|
+
};
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
return json(res, feed);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ---- NARRATE BULK — translate a batch via LLM ----
|
|
288
|
+
if (path === "/narrate" && req.method === "POST") {
|
|
289
|
+
const body = await readBody(req);
|
|
290
|
+
const ids = body.ids as number[] | undefined;
|
|
291
|
+
if (!ids || !Array.isArray(ids) || ids.length === 0) return err(res, "ids array required");
|
|
292
|
+
if (ids.length > 50) return err(res, "max 50 ids per batch");
|
|
293
|
+
|
|
294
|
+
const results: { id: number; narrative: string }[] = [];
|
|
295
|
+
for (const id of ids) {
|
|
296
|
+
const row = db.prepare("SELECT * FROM actions WHERE id = ?").get(id) as any;
|
|
297
|
+
if (!row) continue;
|
|
298
|
+
const payload = JSON.parse(row.payload);
|
|
299
|
+
const narrative = row.narrative ?? await narrate(row.agent, row.service, row.action, payload);
|
|
300
|
+
if (!row.narrative) db.prepare("UPDATE actions SET narrative = ? WHERE id = ?").run(narrative, id);
|
|
301
|
+
results.push({ id, narrative });
|
|
302
|
+
}
|
|
303
|
+
return json(res, results);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ---- STATS ----
|
|
307
|
+
if (path === "/stats" && req.method === "GET") {
|
|
308
|
+
return json(res, getStats());
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ---- ASK — natural language query over the stack ----
|
|
312
|
+
if (path === "/ask" && req.method === "POST") {
|
|
313
|
+
const body = await readBody(req);
|
|
314
|
+
const question = body.question as string | undefined;
|
|
315
|
+
if (!question || typeof question !== "string" || !question.trim()) {
|
|
316
|
+
return err(res, "question (string) required");
|
|
317
|
+
}
|
|
318
|
+
try {
|
|
319
|
+
const result = await ask(question.trim());
|
|
320
|
+
return json(res, result);
|
|
321
|
+
} catch (e: any) {
|
|
322
|
+
return err(res, e.message ?? "Ask failed", 502);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
err(res, "Not found", 404);
|
|
327
|
+
} catch (e) {
|
|
328
|
+
console.error("Unhandled:", e);
|
|
329
|
+
if (!res.headersSent) res.writeHead(500, { "Content-Type": "application/json" });
|
|
330
|
+
res.end(JSON.stringify({ error: "Internal server error" }));
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
server.listen(PORT, HOST, async () => {
|
|
335
|
+
console.log(`Broca running on http://${HOST}:${PORT}`);
|
|
336
|
+
console.log(`Database: ${DB_PATH}`);
|
|
337
|
+
console.log(`Auth: ${AUTH_DISABLED ? "DISABLED" : "enabled"}`);
|
|
338
|
+
await subscribeToAxon();
|
|
339
|
+
});
|
package/src/ui.ts
ADDED
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
export const UI_HTML = `<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>BROCA</title>
|
|
7
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
8
|
+
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@300;400;500;600&family=Syne:wght@700;800&display=swap" rel="stylesheet">
|
|
9
|
+
<style>
|
|
10
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
11
|
+
|
|
12
|
+
:root {
|
|
13
|
+
--bg: #060608;
|
|
14
|
+
--surface: #0c0c10;
|
|
15
|
+
--surface2: #111118;
|
|
16
|
+
--border: #1e1e2e;
|
|
17
|
+
--text: #c8ccd8;
|
|
18
|
+
--muted: #4a4a6a;
|
|
19
|
+
--dim: #2a2a3a;
|
|
20
|
+
--phosphor: #00e5a0;
|
|
21
|
+
--phosphor-dim: #00e5a015;
|
|
22
|
+
--amber: #f5a623;
|
|
23
|
+
--amber-dim: #f5a62318;
|
|
24
|
+
--cyan: #38bdf8;
|
|
25
|
+
--error: #ff5f5f;
|
|
26
|
+
--error-bg: #1a0808;
|
|
27
|
+
--mono: 'IBM Plex Mono', monospace;
|
|
28
|
+
--display: 'Syne', sans-serif;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
html, body {
|
|
32
|
+
height: 100%;
|
|
33
|
+
background: var(--bg);
|
|
34
|
+
color: var(--text);
|
|
35
|
+
font-family: var(--mono);
|
|
36
|
+
font-size: 13px;
|
|
37
|
+
line-height: 1.6;
|
|
38
|
+
overflow: hidden;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/* ── Scanline overlay ── */
|
|
42
|
+
body::before {
|
|
43
|
+
content: '';
|
|
44
|
+
position: fixed;
|
|
45
|
+
inset: 0;
|
|
46
|
+
background: repeating-linear-gradient(
|
|
47
|
+
0deg,
|
|
48
|
+
transparent,
|
|
49
|
+
transparent 2px,
|
|
50
|
+
rgba(0,0,0,0.08) 2px,
|
|
51
|
+
rgba(0,0,0,0.08) 4px
|
|
52
|
+
);
|
|
53
|
+
pointer-events: none;
|
|
54
|
+
z-index: 100;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/* ── Animated grid background ── */
|
|
58
|
+
body::after {
|
|
59
|
+
content: '';
|
|
60
|
+
position: fixed;
|
|
61
|
+
inset: 0;
|
|
62
|
+
background-image:
|
|
63
|
+
linear-gradient(rgba(0,229,160,0.03) 1px, transparent 1px),
|
|
64
|
+
linear-gradient(90deg, rgba(0,229,160,0.03) 1px, transparent 1px);
|
|
65
|
+
background-size: 48px 48px;
|
|
66
|
+
animation: gridDrift 40s linear infinite;
|
|
67
|
+
pointer-events: none;
|
|
68
|
+
z-index: 0;
|
|
69
|
+
}
|
|
70
|
+
@keyframes gridDrift {
|
|
71
|
+
0% { background-position: 0 0; }
|
|
72
|
+
100% { background-position: 48px 48px; }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/* ── Layout ── */
|
|
76
|
+
.layout {
|
|
77
|
+
display: flex;
|
|
78
|
+
flex-direction: column;
|
|
79
|
+
height: 100vh;
|
|
80
|
+
max-width: 860px;
|
|
81
|
+
margin: 0 auto;
|
|
82
|
+
padding: 0 24px;
|
|
83
|
+
position: relative;
|
|
84
|
+
z-index: 1;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/* ── Header ── */
|
|
88
|
+
header {
|
|
89
|
+
padding: 22px 0 18px;
|
|
90
|
+
border-bottom: 1px solid var(--border);
|
|
91
|
+
display: flex;
|
|
92
|
+
align-items: center;
|
|
93
|
+
justify-content: space-between;
|
|
94
|
+
flex-shrink: 0;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.logo {
|
|
98
|
+
display: flex;
|
|
99
|
+
align-items: baseline;
|
|
100
|
+
gap: 12px;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.logo-word {
|
|
104
|
+
font-family: var(--display);
|
|
105
|
+
font-size: 26px;
|
|
106
|
+
font-weight: 800;
|
|
107
|
+
letter-spacing: 4px;
|
|
108
|
+
color: var(--phosphor);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.logo-sub {
|
|
112
|
+
font-size: 10px;
|
|
113
|
+
color: var(--muted);
|
|
114
|
+
letter-spacing: 2px;
|
|
115
|
+
text-transform: uppercase;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.status-bar {
|
|
119
|
+
display: flex;
|
|
120
|
+
align-items: center;
|
|
121
|
+
gap: 16px;
|
|
122
|
+
font-size: 10px;
|
|
123
|
+
color: var(--muted);
|
|
124
|
+
letter-spacing: 1px;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.status-dot {
|
|
128
|
+
width: 6px;
|
|
129
|
+
height: 6px;
|
|
130
|
+
border-radius: 50%;
|
|
131
|
+
background: var(--phosphor);
|
|
132
|
+
display: inline-block;
|
|
133
|
+
margin-right: 4px;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/* ── Feed ── */
|
|
137
|
+
.feed {
|
|
138
|
+
flex: 1;
|
|
139
|
+
overflow-y: auto;
|
|
140
|
+
padding: 24px 0 12px;
|
|
141
|
+
display: flex;
|
|
142
|
+
flex-direction: column;
|
|
143
|
+
gap: 20px;
|
|
144
|
+
scrollbar-width: thin;
|
|
145
|
+
scrollbar-color: var(--border) transparent;
|
|
146
|
+
}
|
|
147
|
+
.feed::-webkit-scrollbar { width: 4px; }
|
|
148
|
+
.feed::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
|
149
|
+
|
|
150
|
+
/* ── Messages ── */
|
|
151
|
+
.msg {
|
|
152
|
+
display: flex;
|
|
153
|
+
flex-direction: column;
|
|
154
|
+
gap: 5px;
|
|
155
|
+
animation: msgIn 0.25s ease-out both;
|
|
156
|
+
}
|
|
157
|
+
@keyframes msgIn {
|
|
158
|
+
from { opacity: 0; transform: translateY(10px); }
|
|
159
|
+
to { opacity: 1; transform: translateY(0); }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/* Question bubble — command prompt style */
|
|
163
|
+
.msg.question .bubble {
|
|
164
|
+
align-self: flex-end;
|
|
165
|
+
max-width: 80%;
|
|
166
|
+
background: var(--amber-dim);
|
|
167
|
+
border: 1px solid #f5a62330;
|
|
168
|
+
border-radius: 2px 12px 12px 2px;
|
|
169
|
+
color: var(--amber);
|
|
170
|
+
padding: 10px 14px 10px 36px;
|
|
171
|
+
position: relative;
|
|
172
|
+
font-weight: 500;
|
|
173
|
+
}
|
|
174
|
+
.msg.question .bubble::before {
|
|
175
|
+
content: '>';
|
|
176
|
+
position: absolute;
|
|
177
|
+
left: 14px;
|
|
178
|
+
top: 10px;
|
|
179
|
+
color: var(--phosphor);
|
|
180
|
+
font-weight: 600;
|
|
181
|
+
text-shadow: 0 0 8px var(--phosphor);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/* Answer bubble — intelligence responding */
|
|
185
|
+
.msg.answer .bubble {
|
|
186
|
+
align-self: flex-start;
|
|
187
|
+
max-width: 90%;
|
|
188
|
+
background: var(--surface2);
|
|
189
|
+
border: 1px solid var(--border);
|
|
190
|
+
border-left: 2px solid var(--phosphor);
|
|
191
|
+
border-radius: 0 12px 12px 0;
|
|
192
|
+
color: var(--text);
|
|
193
|
+
padding: 12px 16px;
|
|
194
|
+
box-shadow: inset 0 0 30px rgba(0,229,160,0.03);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/* Thinking ── */
|
|
198
|
+
.msg.thinking .bubble {
|
|
199
|
+
align-self: flex-start;
|
|
200
|
+
background: var(--surface);
|
|
201
|
+
border: 1px solid var(--border);
|
|
202
|
+
border-left: 2px solid var(--muted);
|
|
203
|
+
border-radius: 0 12px 12px 0;
|
|
204
|
+
padding: 12px 16px;
|
|
205
|
+
color: var(--muted);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
.dot-flashing {
|
|
209
|
+
display: inline-flex;
|
|
210
|
+
gap: 5px;
|
|
211
|
+
align-items: center;
|
|
212
|
+
}
|
|
213
|
+
.dot-flashing span {
|
|
214
|
+
width: 5px;
|
|
215
|
+
height: 5px;
|
|
216
|
+
border-radius: 50%;
|
|
217
|
+
background: var(--phosphor);
|
|
218
|
+
animation: dotPulse 1.4s ease-in-out infinite;
|
|
219
|
+
}
|
|
220
|
+
.dot-flashing span:nth-child(2) { animation-delay: 0.2s; }
|
|
221
|
+
.dot-flashing span:nth-child(3) { animation-delay: 0.4s; }
|
|
222
|
+
@keyframes dotPulse {
|
|
223
|
+
0%, 80%, 100% { transform: scale(0.6); opacity: 0.3; }
|
|
224
|
+
40% { transform: scale(1); opacity: 1; }
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/* Scan line on thinking bubble */
|
|
228
|
+
.thinking-scan {
|
|
229
|
+
height: 1px;
|
|
230
|
+
background: linear-gradient(90deg, transparent, var(--phosphor), transparent);
|
|
231
|
+
margin-top: 8px;
|
|
232
|
+
animation: scan 2s linear infinite;
|
|
233
|
+
opacity: 0.6;
|
|
234
|
+
}
|
|
235
|
+
@keyframes scan {
|
|
236
|
+
0% { transform: translateX(-100%); }
|
|
237
|
+
100% { transform: translateX(200%); }
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/* Error */
|
|
241
|
+
.msg.error .bubble {
|
|
242
|
+
align-self: flex-start;
|
|
243
|
+
max-width: 90%;
|
|
244
|
+
background: var(--error-bg);
|
|
245
|
+
border: 1px solid #ff5f5f30;
|
|
246
|
+
border-left: 2px solid var(--error);
|
|
247
|
+
border-radius: 0 12px 12px 0;
|
|
248
|
+
color: var(--error);
|
|
249
|
+
padding: 12px 16px;
|
|
250
|
+
}
|
|
251
|
+
.msg.error .bubble::before {
|
|
252
|
+
content: '! ';
|
|
253
|
+
font-weight: 700;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
.bubble { font-size: 13px; line-height: 1.65; }
|
|
257
|
+
|
|
258
|
+
/* Meta + service tag */
|
|
259
|
+
.meta {
|
|
260
|
+
font-size: 10px;
|
|
261
|
+
color: var(--muted);
|
|
262
|
+
display: flex;
|
|
263
|
+
gap: 8px;
|
|
264
|
+
align-items: center;
|
|
265
|
+
padding: 0 6px;
|
|
266
|
+
letter-spacing: 0.5px;
|
|
267
|
+
}
|
|
268
|
+
.msg.question .meta { align-self: flex-end; }
|
|
269
|
+
|
|
270
|
+
.meta .service {
|
|
271
|
+
background: var(--surface2);
|
|
272
|
+
border: 1px solid var(--border);
|
|
273
|
+
border-radius: 3px;
|
|
274
|
+
padding: 1px 7px;
|
|
275
|
+
font-family: var(--mono);
|
|
276
|
+
color: var(--phosphor);
|
|
277
|
+
letter-spacing: 1px;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/* Raw data */
|
|
281
|
+
.details {
|
|
282
|
+
font-size: 11px;
|
|
283
|
+
color: var(--muted);
|
|
284
|
+
padding: 0 6px;
|
|
285
|
+
}
|
|
286
|
+
.details summary {
|
|
287
|
+
cursor: pointer;
|
|
288
|
+
user-select: none;
|
|
289
|
+
letter-spacing: 1px;
|
|
290
|
+
text-transform: uppercase;
|
|
291
|
+
font-size: 10px;
|
|
292
|
+
}
|
|
293
|
+
.details summary::marker { color: var(--phosphor); }
|
|
294
|
+
.details summary:hover { color: var(--phosphor); }
|
|
295
|
+
.details pre {
|
|
296
|
+
margin-top: 8px;
|
|
297
|
+
padding: 12px;
|
|
298
|
+
background: var(--surface);
|
|
299
|
+
border: 1px solid var(--border);
|
|
300
|
+
border-radius: 4px;
|
|
301
|
+
overflow-x: auto;
|
|
302
|
+
white-space: pre-wrap;
|
|
303
|
+
word-break: break-all;
|
|
304
|
+
line-height: 1.5;
|
|
305
|
+
color: var(--muted);
|
|
306
|
+
max-height: 280px;
|
|
307
|
+
overflow-y: auto;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/* ── Recent activity feed ── */
|
|
311
|
+
.feed-section {
|
|
312
|
+
border: 1px solid var(--border);
|
|
313
|
+
border-radius: 6px;
|
|
314
|
+
background: var(--surface);
|
|
315
|
+
overflow: hidden;
|
|
316
|
+
}
|
|
317
|
+
.feed-section-header {
|
|
318
|
+
padding: 8px 14px;
|
|
319
|
+
background: var(--surface2);
|
|
320
|
+
border-bottom: 1px solid var(--border);
|
|
321
|
+
display: flex;
|
|
322
|
+
align-items: center;
|
|
323
|
+
gap: 8px;
|
|
324
|
+
}
|
|
325
|
+
.feed-section-header h2 {
|
|
326
|
+
font-size: 9px;
|
|
327
|
+
text-transform: uppercase;
|
|
328
|
+
letter-spacing: 2px;
|
|
329
|
+
color: var(--muted);
|
|
330
|
+
font-family: var(--mono);
|
|
331
|
+
font-weight: 400;
|
|
332
|
+
}
|
|
333
|
+
.feed-section-header::before {
|
|
334
|
+
content: '';
|
|
335
|
+
width: 4px;
|
|
336
|
+
height: 4px;
|
|
337
|
+
border-radius: 50%;
|
|
338
|
+
background: var(--phosphor);
|
|
339
|
+
}
|
|
340
|
+
.event {
|
|
341
|
+
font-size: 11px;
|
|
342
|
+
color: var(--muted);
|
|
343
|
+
padding: 7px 14px;
|
|
344
|
+
border-bottom: 1px solid #1e1e2e55;
|
|
345
|
+
display: flex;
|
|
346
|
+
gap: 14px;
|
|
347
|
+
align-items: baseline;
|
|
348
|
+
font-family: var(--mono);
|
|
349
|
+
transition: background 0.15s;
|
|
350
|
+
}
|
|
351
|
+
.event:last-child { border-bottom: none; }
|
|
352
|
+
.event:hover { background: var(--surface2); color: var(--text); }
|
|
353
|
+
.event .time {
|
|
354
|
+
white-space: nowrap;
|
|
355
|
+
font-size: 10px;
|
|
356
|
+
color: var(--dim);
|
|
357
|
+
min-width: 72px;
|
|
358
|
+
flex-shrink: 0;
|
|
359
|
+
font-variant-numeric: tabular-nums;
|
|
360
|
+
}
|
|
361
|
+
.event .text { color: #8888aa; }
|
|
362
|
+
.event:hover .text { color: var(--text); }
|
|
363
|
+
|
|
364
|
+
/* ── Input row ── */
|
|
365
|
+
.input-row {
|
|
366
|
+
padding: 14px 0 22px;
|
|
367
|
+
display: flex;
|
|
368
|
+
gap: 10px;
|
|
369
|
+
border-top: 1px solid var(--border);
|
|
370
|
+
flex-shrink: 0;
|
|
371
|
+
position: relative;
|
|
372
|
+
}
|
|
373
|
+
.input-row::before {
|
|
374
|
+
content: '';
|
|
375
|
+
position: absolute;
|
|
376
|
+
top: 0;
|
|
377
|
+
left: 0;
|
|
378
|
+
right: 0;
|
|
379
|
+
height: 1px;
|
|
380
|
+
background: linear-gradient(90deg, transparent, var(--phosphor), transparent);
|
|
381
|
+
opacity: 0.3;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
.input-wrap {
|
|
385
|
+
flex: 1;
|
|
386
|
+
position: relative;
|
|
387
|
+
display: flex;
|
|
388
|
+
align-items: flex-start;
|
|
389
|
+
}
|
|
390
|
+
.input-wrap::before {
|
|
391
|
+
content: '>';
|
|
392
|
+
position: absolute;
|
|
393
|
+
left: 14px;
|
|
394
|
+
top: 11px;
|
|
395
|
+
color: var(--phosphor);
|
|
396
|
+
font-weight: 600;
|
|
397
|
+
font-size: 14px;
|
|
398
|
+
pointer-events: none;
|
|
399
|
+
z-index: 1;
|
|
400
|
+
line-height: 1.5;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
textarea {
|
|
404
|
+
flex: 1;
|
|
405
|
+
width: 100%;
|
|
406
|
+
background: var(--surface);
|
|
407
|
+
border: 1px solid var(--border);
|
|
408
|
+
border-radius: 6px;
|
|
409
|
+
color: var(--amber);
|
|
410
|
+
padding: 10px 14px 10px 34px;
|
|
411
|
+
font-size: 13px;
|
|
412
|
+
font-family: var(--mono);
|
|
413
|
+
resize: none;
|
|
414
|
+
outline: none;
|
|
415
|
+
line-height: 1.5;
|
|
416
|
+
min-height: 44px;
|
|
417
|
+
max-height: 140px;
|
|
418
|
+
transition: border-color 0.2s, box-shadow 0.2s;
|
|
419
|
+
caret-color: var(--phosphor);
|
|
420
|
+
}
|
|
421
|
+
textarea:focus {
|
|
422
|
+
border-color: #00e5a050;
|
|
423
|
+
}
|
|
424
|
+
textarea::placeholder { color: var(--muted); font-weight: 300; }
|
|
425
|
+
|
|
426
|
+
button#send {
|
|
427
|
+
background: transparent;
|
|
428
|
+
border: 1px solid var(--phosphor);
|
|
429
|
+
border-radius: 6px;
|
|
430
|
+
color: var(--phosphor);
|
|
431
|
+
cursor: pointer;
|
|
432
|
+
font-family: var(--mono);
|
|
433
|
+
font-size: 11px;
|
|
434
|
+
font-weight: 600;
|
|
435
|
+
letter-spacing: 2px;
|
|
436
|
+
text-transform: uppercase;
|
|
437
|
+
padding: 0 20px;
|
|
438
|
+
min-height: 44px;
|
|
439
|
+
min-width: 80px;
|
|
440
|
+
transition: background 0.15s, color 0.15s;
|
|
441
|
+
}
|
|
442
|
+
button#send:hover {
|
|
443
|
+
background: var(--phosphor);
|
|
444
|
+
color: var(--bg);
|
|
445
|
+
}
|
|
446
|
+
button#send:active {
|
|
447
|
+
opacity: 0.8;
|
|
448
|
+
}
|
|
449
|
+
button#send:disabled {
|
|
450
|
+
opacity: 0.25;
|
|
451
|
+
cursor: not-allowed;
|
|
452
|
+
}
|
|
453
|
+
</style>
|
|
454
|
+
</head>
|
|
455
|
+
<body>
|
|
456
|
+
<div class="layout">
|
|
457
|
+
<header>
|
|
458
|
+
<div class="logo">
|
|
459
|
+
<span class="logo-word">BROCA</span>
|
|
460
|
+
<span class="logo-sub">agent OS explorer</span>
|
|
461
|
+
</div>
|
|
462
|
+
<div class="status-bar">
|
|
463
|
+
<span><span class="status-dot"></span>ONLINE</span>
|
|
464
|
+
</div>
|
|
465
|
+
</header>
|
|
466
|
+
|
|
467
|
+
<div class="feed" id="feed"></div>
|
|
468
|
+
|
|
469
|
+
<div class="input-row">
|
|
470
|
+
<div class="input-wrap">
|
|
471
|
+
<textarea id="input" placeholder="what tasks are blocked? what did loom do today? which agents are online?" rows="1"></textarea>
|
|
472
|
+
</div>
|
|
473
|
+
<button id="send">SEND</button>
|
|
474
|
+
</div>
|
|
475
|
+
</div>
|
|
476
|
+
|
|
477
|
+
<script>
|
|
478
|
+
const feed = document.getElementById('feed');
|
|
479
|
+
const input = document.getElementById('input');
|
|
480
|
+
const send = document.getElementById('send');
|
|
481
|
+
|
|
482
|
+
function timeAgo(iso) {
|
|
483
|
+
const d = new Date(iso.includes('T') ? iso : iso + 'Z');
|
|
484
|
+
const s = Math.floor((Date.now() - d) / 1000);
|
|
485
|
+
if (s < 60) return s + 's ago';
|
|
486
|
+
if (s < 3600) return Math.floor(s/60) + 'm ago';
|
|
487
|
+
if (s < 86400) return Math.floor(s/3600) + 'h ago';
|
|
488
|
+
return Math.floor(s/86400) + 'd ago';
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function addMsg(type, content, meta) {
|
|
492
|
+
const div = document.createElement('div');
|
|
493
|
+
div.className = 'msg ' + type;
|
|
494
|
+
|
|
495
|
+
const bubble = document.createElement('div');
|
|
496
|
+
bubble.className = 'bubble';
|
|
497
|
+
|
|
498
|
+
if (type === 'thinking') {
|
|
499
|
+
bubble.innerHTML = '<div class="dot-flashing"><span></span><span></span><span></span></div><div class="thinking-scan"></div>';
|
|
500
|
+
} else {
|
|
501
|
+
bubble.textContent = content;
|
|
502
|
+
}
|
|
503
|
+
div.appendChild(bubble);
|
|
504
|
+
|
|
505
|
+
if (meta) {
|
|
506
|
+
const m = document.createElement('div');
|
|
507
|
+
m.className = 'meta';
|
|
508
|
+
if (meta.service) {
|
|
509
|
+
const s = document.createElement('span');
|
|
510
|
+
s.className = 'service';
|
|
511
|
+
s.textContent = meta.service + ' ' + meta.method + ' ' + meta.path;
|
|
512
|
+
m.appendChild(s);
|
|
513
|
+
}
|
|
514
|
+
div.appendChild(m);
|
|
515
|
+
|
|
516
|
+
if (meta.raw) {
|
|
517
|
+
const det = document.createElement('details');
|
|
518
|
+
det.className = 'details';
|
|
519
|
+
const sum = document.createElement('summary');
|
|
520
|
+
sum.textContent = 'raw data';
|
|
521
|
+
const pre = document.createElement('pre');
|
|
522
|
+
pre.textContent = JSON.stringify(meta.raw, null, 2);
|
|
523
|
+
det.appendChild(sum);
|
|
524
|
+
det.appendChild(pre);
|
|
525
|
+
div.appendChild(det);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
feed.appendChild(div);
|
|
530
|
+
feed.scrollTop = feed.scrollHeight;
|
|
531
|
+
return div;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
async function ask(question) {
|
|
535
|
+
addMsg('question', question);
|
|
536
|
+
const thinking = addMsg('thinking');
|
|
537
|
+
send.disabled = true;
|
|
538
|
+
input.disabled = true;
|
|
539
|
+
|
|
540
|
+
try {
|
|
541
|
+
const res = await fetch('/ask', {
|
|
542
|
+
method: 'POST',
|
|
543
|
+
headers: { 'Content-Type': 'application/json' },
|
|
544
|
+
body: JSON.stringify({ question }),
|
|
545
|
+
signal: AbortSignal.timeout(360000),
|
|
546
|
+
});
|
|
547
|
+
const data = await res.json();
|
|
548
|
+
thinking.remove();
|
|
549
|
+
|
|
550
|
+
if (!res.ok) {
|
|
551
|
+
addMsg('error', data.error || 'Something went wrong.');
|
|
552
|
+
} else {
|
|
553
|
+
addMsg('answer', data.answer, { service: data.plan.service, method: data.plan.method, path: data.plan.path, raw: data.raw });
|
|
554
|
+
}
|
|
555
|
+
} catch (e) {
|
|
556
|
+
thinking.remove();
|
|
557
|
+
addMsg('error', e.name === 'TimeoutError' ? 'Timed out — the model is loading, try again in a moment.' : e.message);
|
|
558
|
+
} finally {
|
|
559
|
+
send.disabled = false;
|
|
560
|
+
input.disabled = false;
|
|
561
|
+
input.focus();
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
send.addEventListener('click', () => {
|
|
566
|
+
const q = input.value.trim();
|
|
567
|
+
if (!q) return;
|
|
568
|
+
input.value = '';
|
|
569
|
+
autoResize();
|
|
570
|
+
ask(q);
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
input.addEventListener('keydown', e => {
|
|
574
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
575
|
+
e.preventDefault();
|
|
576
|
+
send.click();
|
|
577
|
+
}
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
function autoResize() {
|
|
581
|
+
input.style.height = 'auto';
|
|
582
|
+
input.style.height = Math.min(input.scrollHeight, 140) + 'px';
|
|
583
|
+
}
|
|
584
|
+
input.addEventListener('input', autoResize);
|
|
585
|
+
|
|
586
|
+
// Load recent feed on start
|
|
587
|
+
fetch('/feed?limit=8').then(r => r.json()).then(events => {
|
|
588
|
+
if (!events.length) return;
|
|
589
|
+
const section = document.createElement('div');
|
|
590
|
+
section.className = 'feed-section';
|
|
591
|
+
|
|
592
|
+
const hdr = document.createElement('div');
|
|
593
|
+
hdr.className = 'feed-section-header';
|
|
594
|
+
const h = document.createElement('h2');
|
|
595
|
+
h.textContent = 'Recent activity';
|
|
596
|
+
hdr.appendChild(h);
|
|
597
|
+
section.appendChild(hdr);
|
|
598
|
+
|
|
599
|
+
events.reverse().forEach(e => {
|
|
600
|
+
const row = document.createElement('div');
|
|
601
|
+
row.className = 'event';
|
|
602
|
+
row.innerHTML = \`<span class="time">\${timeAgo(e.created_at)}</span><span class="text">\${e.narrative || e.action}</span>\`;
|
|
603
|
+
section.appendChild(row);
|
|
604
|
+
});
|
|
605
|
+
feed.appendChild(section);
|
|
606
|
+
}).catch(() => {});
|
|
607
|
+
|
|
608
|
+
input.focus();
|
|
609
|
+
</script>
|
|
610
|
+
</body>
|
|
611
|
+
</html>`;
|