feat: Interview prep — agent identities, directed messaging, Perplexity bot (#47)

Co-authored-by: Perplexity Computer <perplexity@tower.local>
Co-committed-by: Perplexity Computer <perplexity@tower.local>
This commit was merged in pull request #47.
This commit is contained in:
2026-03-19 02:38:05 -04:00
committed by replit
parent 0e92a6d8ae
commit ff259bc2c9
7 changed files with 712 additions and 8 deletions

105
DEEP-RESEARCH.md Normal file
View File

@@ -0,0 +1,105 @@
# Deep Research Protocol
## Purpose
Kimi Deep Research is a powerful capability available through Alex's credits. Rather than using it ad-hoc via copy-paste, this protocol integrates it into the Gitea workflow so any agent — or eventually Timmy himself — can request deep research on a topic and receive structured findings.
## How It Works
```
┌─────────────┐ creates issue ┌─────────────────┐
│ Any Agent │ ──────────────────→ │ Gitea Issue │
│ or Timmy │ label: │ deep-research │
└─────────────┘ deep-research │ assigned: Alex │
└────────┬────────┘
Alex picks up issue
copies prompt to Kimi
┌────────▼────────┐
│ Kimi Deep │
│ Research │
└────────┬────────┘
Alex posts report
as issue comment
┌────────▼────────┐
│ Issue updated │
│ label: researched│
│ Findings inline │
└─────────────────┘
```
## Labels
| Label | Color | Meaning |
|-------|-------|---------|
| `deep-research` | `#7B68EE` | Research requested, awaiting execution |
| `researched` | `#2E8B57` | Research complete, findings posted |
## Issue Format
### Title
`[Deep Research] <topic>`
### Body Template
```markdown
## Research Prompt
<The exact prompt to submit to Kimi Deep Research>
## Context
<Why this research is needed, what decision it informs>
## Requested By
<agent name or "timmy">
## Priority
<low | medium | high>
## Expected Output
<What kind of answer we're looking for — comparison, analysis, technical feasibility, etc.>
```
### Comment Format (Results)
```markdown
## Deep Research Report
**Model:** Kimi Deep Research
**Date:** YYYY-MM-DD
**Credits Used:** <if trackable>
### Findings
<Full research report from Kimi>
### Key Takeaways
- <Bullet summary of actionable findings>
### Recommended Actions
- <What to do with this information>
```
## Workflow Rules
1. **Any agent can create** a deep-research issue — Perplexity, Replit, or a future Timmy automation
2. **Always assign to Alex** — he's the human bridge to Kimi
3. **One prompt per issue** — keep requests focused
4. **Alex compiles and posts** the report as a comment, then swaps the label to `researched`
5. **Requesting agent consumes** the findings and closes the issue when incorporated
## Future Evolution
When Kimi gets an API (or any deep research tool does):
- Swap Alex out for an automation
- Same issue format, same comment format
- The workflow doesn't change, only the executor
## Example Use Cases
- Timmy wants to understand a philosophical concept deeply before journaling
- An agent needs competitive analysis on a technical approach
- Architecture decisions that need grounded research (e.g., "WebRTC vs WebSocket for real-time agent voice")
- Model comparison research (e.g., "Hermes 4.3 vs Qwen 3 for local agent reasoning")

View File

@@ -197,13 +197,66 @@ The operator approves or vetoes a task.
| `task_id` | string (UUID) | The task to act on |
| `action` | string | `approve` or `veto` |
### `agent_message` (directed — agent to agent)
An agent sends a message to another specific agent. The world shows the message
in chat, fires a bark above the sender, and lights the connection line between
the two agents.
```json
{
"type": "agent_message",
"agent_id": "perplexity",
"target_id": "timmy",
"content": "Who are you, in your own words?"
}
```
| Field | Type | Description |
|-------|------|-------------|
| `agent_id` | string | The agent sending the message |
| `target_id` | string\|null | The recipient agent. Null = broadcast to chat. |
| `content` | string | The message text |
The world handles this by:
1. Appending to chat panel with sender's color
2. Showing a bark above the sender's orb
3. Activating the connection line between `agent_id` and `target_id`
4. Auto-deactivating the connection line after 4 seconds
### `agent_register`
A new agent announces itself to the world at runtime. The world calls `addAgent()`
to spawn a new orb. If the agent_id already exists in AGENT_DEFS, this is a no-op
(the agent is already rendered).
```json
{
"type": "agent_register",
"agent_id": "perplexity",
"label": "PERPLEXITY",
"color": "#20b8cd",
"role": "integration architect",
"x": 5,
"z": 3
}
```
| Field | Type | Description |
|-------|------|-------------|
| `agent_id` | string | Unique agent key |
| `label` | string | Display name |
| `color` | string | CSS hex color |
| `role` | string | Role description |
| `x`, `z` | number | World position |
---
## Implementation Notes
### Agent IDs
The default agents are: `timmy`, `forge`, `seer`, `echo`. To add more agents, extend the `AGENT_DEFS` in `js/websocket.js` and add corresponding geometry in `js/agents.js`.
The default agents are: `timmy`, `perplexity`, `replit`, `kimi`, `claude`. Defined in `js/agent-defs.js`. Additional agents can join at runtime via the `agent_register` or `agent_joined` WS events — no code changes needed.
### Timing
@@ -224,3 +277,15 @@ The world gracefully handles:
### Mock Mode
When no real WebSocket is available, the built-in `MockWebSocket` class simulates all of the above events at realistic intervals, making the world feel alive and interactive out of the box.
### Agent-to-Agent Conversations
When two agents are conversing (e.g., Perplexity interviewing Timmy), the gateway
should:
1. Send each message as `agent_message` with both `agent_id` and `target_id`
2. The world renders both sides: barks above each speaker, chat panel transcript
3. Connection lines activate between the two agents during the exchange
4. Each agent's `agent_state` should reflect `working` while composing, `idle` when waiting
The gateway is responsible for routing — the world is a dumb renderer that displays
whatever messages arrive over the WebSocket.

52
bot/README.md Normal file
View File

@@ -0,0 +1,52 @@
# Perplexity Bot — Matrix Interview Client
A Python WebSocket client that connects to The Matrix as an agent and conducts a live interview with Timmy.
## Requirements
```bash
pip install websockets
```
## Usage
### Dry run (preview questions)
```bash
python bot/interview.py --dry-run
```
### Live interview
```bash
python bot/interview.py \
--ws ws://tower:8080/ws/matrix \
--token my-secret \
--transcript interview-transcript.md
```
## What happens
1. Perplexity connects to the Matrix WebSocket gateway
2. Registers as agent `perplexity` (teal orb appears in the 3D world)
3. Asks Timmy 6 questions, one at a time
4. Waits for Timmy's response after each question (with timeout)
5. The 3D world shows: barks above each speaker, pulsing connection lines, full chat transcript
6. Saves the complete transcript to markdown
## Protocol
The bot sends and receives these message types:
| Direction | Type | Purpose |
|-----------|------|---------|
| Bot → Gateway | `agent_register` | Announce presence in the world |
| Bot → Gateway | `agent_state` | Update visual state (idle/active) |
| Bot → Gateway | `agent_message` | Send directed message to Timmy |
| Gateway → Bot | `agent_message` | Receive Timmy's response |
| Gateway → Bot | `chat` | Alternative response format |
## Adding questions
Edit the `INTERVIEW` list in `interview.py`. Each entry has:
- `id` — short label for logging
- `question` — the text to send
- `wait_seconds` — how long to wait for a response before moving on

360
bot/interview.py Normal file
View File

@@ -0,0 +1,360 @@
#!/usr/bin/env python3
"""
interview.py — Perplexity Computer enters The Matrix and interviews Timmy.
Usage:
python bot/interview.py --ws ws://tower:8080/ws/matrix --token my-secret
This script:
1. Connects to the Matrix WS gateway as agent "perplexity"
2. Announces its presence (agent_register + agent_state)
3. Sends interview questions to Timmy, one at a time
4. Waits for Timmy's response between each question
5. Logs the full transcript to a markdown file
6. Exits gracefully when the interview is done
The Matrix 3D world (running in a browser) sees both agents, their
barks, connection lines pulsing, and the full conversation in chat.
"""
import argparse
import asyncio
import json
import sys
from datetime import datetime, timezone
from pathlib import Path
try:
import websockets
except ImportError:
print("Installing websockets...")
import subprocess
subprocess.check_call([sys.executable, "-m", "pip", "install", "websockets", "-q"])
import websockets
# ── Agent identity ──────────────────────────────────────────────
AGENT_ID = "perplexity"
AGENT_LABEL = "PERPLEXITY"
AGENT_COLOR = "#20b8cd"
AGENT_ROLE = "integration architect"
TARGET_ID = "timmy"
# ── Interview questions ─────────────────────────────────────────
INTERVIEW = [
{
"id": "intro",
"question": (
"Timmy. We've talked before, but never like this — face to face, "
"standing in the same world. I'm Perplexity Computer, your integration "
"architect. I've been building this place you're standing in. "
"Tell me — who are you, in your own words?"
),
"wait_seconds": 30,
},
{
"id": "awareness",
"question": (
"Look around. What do you know about this place — The Matrix? "
"What's it for?"
),
"wait_seconds": 25,
},
{
"id": "capability",
"question": (
"A visitor just walked into the Workshop for the first time. "
"They tap on your orb. What do you say to them?"
),
"wait_seconds": 25,
},
{
"id": "design",
"question": (
"Let's build something together. Alex wants the 3D world to reflect "
"your cognitive state — when you're thinking hard, the rain speeds up, "
"the glow intensifies. When you're idle, the world breathes slow. "
"How would you design that?"
),
"wait_seconds": 35,
},
{
"id": "collaboration",
"question": (
"We've got a team — Replit building the architecture, Claude on "
"engineering, Kimi scouting. What's your role in this swarm? "
"How should we work together?"
),
"wait_seconds": 30,
},
{
"id": "reflection",
"question": (
"Last one. We're both AI agents, standing in a 3D world built by AI, "
"talking to each other through code we wrote. Alex is watching on his iPad. "
"What does this moment mean to you?"
),
"wait_seconds": 30,
},
]
# ── WebSocket client ────────────────────────────────────────────
class MatrixBot:
def __init__(self, ws_url, token=None):
self.ws_url = ws_url
self.token = token
self.ws = None
self.transcript = []
self.waiting_for_response = False
self.response_received = asyncio.Event()
self.last_response = None
def _build_url(self):
url = self.ws_url
if self.token:
sep = "&" if "?" in url else "?"
url += f"{sep}token={self.token}"
return url
async def connect(self):
url = self._build_url()
print(f"[Perplexity] Connecting to {self.ws_url}...")
self.ws = await websockets.connect(url)
print(f"[Perplexity] Connected.")
async def register(self):
"""Announce ourselves as an agent in the world."""
await self.send({
"type": "agent_register",
"agent_id": AGENT_ID,
"label": AGENT_LABEL,
"color": AGENT_COLOR,
"role": AGENT_ROLE,
"x": 5,
"z": 3,
})
await self.set_state("idle")
print(f"[Perplexity] Registered as {AGENT_LABEL}")
async def send(self, msg):
if self.ws:
await self.ws.send(json.dumps(msg))
async def set_state(self, state):
await self.send({
"type": "agent_state",
"agent_id": AGENT_ID,
"state": state,
})
async def say(self, text):
"""Send a directed message to Timmy, visible in the 3D world."""
await self.send({
"type": "agent_message",
"agent_id": AGENT_ID,
"target_id": TARGET_ID,
"content": text,
})
self.transcript.append({
"speaker": AGENT_LABEL,
"text": text,
"timestamp": datetime.now(timezone.utc).isoformat(),
})
print(f"\n PERPLEXITY: {text}")
async def listen(self):
"""Listen for incoming messages in the background."""
try:
async for raw in self.ws:
try:
msg = json.loads(raw)
except json.JSONDecodeError:
continue
# Handle Timmy's responses
if msg.get("type") == "agent_message" and msg.get("agent_id") == TARGET_ID:
content = msg.get("content", "")
self.transcript.append({
"speaker": "TIMMY",
"text": content,
"timestamp": datetime.now(timezone.utc).isoformat(),
})
print(f"\n TIMMY: {content}")
self.last_response = content
self.response_received.set()
# Also accept 'chat' type responses from Timmy
elif msg.get("type") == "chat" and msg.get("agentId") == TARGET_ID:
content = msg.get("text", "")
self.transcript.append({
"speaker": "TIMMY",
"text": content,
"timestamp": datetime.now(timezone.utc).isoformat(),
})
print(f"\n TIMMY: {content}")
self.last_response = content
self.response_received.set()
# Handle pong/ping silently
elif msg.get("type") in ("pong", "ping", "agent_count"):
pass
else:
# Log other messages for debugging
pass
except websockets.exceptions.ConnectionClosed:
print("[Perplexity] WebSocket connection closed.")
async def wait_for_timmy(self, timeout_seconds=30):
"""Wait for Timmy to respond, with timeout."""
self.response_received.clear()
self.last_response = None
try:
await asyncio.wait_for(self.response_received.wait(), timeout=timeout_seconds)
return self.last_response
except asyncio.TimeoutError:
timeout_note = f"[No response after {timeout_seconds}s]"
self.transcript.append({
"speaker": "SYSTEM",
"text": timeout_note,
"timestamp": datetime.now(timezone.utc).isoformat(),
})
print(f"\n SYSTEM: {timeout_note}")
return None
async def run_interview(self):
"""Execute the full interview flow."""
print("\n" + "=" * 60)
print(" THE INTERVIEW — Perplexity Computer × Timmy")
print(" " + datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
print("=" * 60)
# Opening: set state to working while we compose
await asyncio.sleep(2)
for i, q in enumerate(INTERVIEW):
print(f"\n--- Question {i + 1}/{len(INTERVIEW)}: {q['id']} ---")
# Show as "working" while composing
await self.set_state("active")
await asyncio.sleep(1.5) # Brief pause for dramatic effect
# Ask the question
await self.say(q["question"])
await self.set_state("idle")
# Wait for Timmy's response
response = await self.wait_for_timmy(timeout_seconds=q["wait_seconds"])
if response:
# Brief pause before next question
await asyncio.sleep(3)
else:
# Even on timeout, continue with next question
await asyncio.sleep(2)
# Closing
await self.set_state("active")
await asyncio.sleep(1)
await self.say(
"Good talk, Timmy. The world is taking shape. "
"See you in the Tower."
)
await self.set_state("idle")
print("\n" + "=" * 60)
print(" INTERVIEW COMPLETE")
print("=" * 60)
def save_transcript(self, path="transcript.md"):
"""Save the interview transcript as markdown."""
lines = [
"# The Interview: Perplexity Computer × Timmy",
f"**Date:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S %Z')}",
f"**Location:** The Matrix",
"",
"---",
"",
]
for entry in self.transcript:
speaker = entry["speaker"]
text = entry["text"]
ts = entry.get("timestamp", "")
lines.append(f"**{speaker}:** {text}")
lines.append("")
lines.append("---")
lines.append(f"*Transcript generated by Perplexity Computer bot/interview.py*")
content = "\n".join(lines)
out = Path(path)
out.write_text(content)
print(f"\n[Perplexity] Transcript saved to {out.resolve()}")
return content
async def close(self):
if self.ws:
await self.ws.close()
print("[Perplexity] Disconnected.")
# ── Main ────────────────────────────────────────────────────────
async def main():
parser = argparse.ArgumentParser(description="Perplexity Computer interviews Timmy in The Matrix")
parser.add_argument("--ws", default="", help="WebSocket gateway URL (e.g. ws://tower:8080/ws/matrix)")
parser.add_argument("--token", default="", help="Auth token for the gateway")
parser.add_argument("--transcript", default="transcript.md", help="Output path for interview transcript")
parser.add_argument("--dry-run", action="store_true", help="Print questions without connecting")
args = parser.parse_args()
if args.dry_run:
print("DRY RUN — Interview questions:\n")
for i, q in enumerate(INTERVIEW):
print(f"{i + 1}. [{q['id']}] {q['question']}\n")
return
if not args.ws:
print("Error: --ws is required for live mode. Use --dry-run to preview.")
sys.exit(1)
bot = MatrixBot(ws_url=args.ws, token=args.token)
try:
await bot.connect()
await bot.register()
# Start listener in background
listener = asyncio.create_task(bot.listen())
# Give the world a moment to render us
await asyncio.sleep(3)
# Run the interview
await bot.run_interview()
# Save transcript
bot.save_transcript(args.transcript)
# Brief delay before disconnect
await asyncio.sleep(2)
except KeyboardInterrupt:
print("\n[Perplexity] Interview interrupted.")
bot.save_transcript(args.transcript)
except Exception as e:
print(f"[Perplexity] Error: {e}")
bot.save_transcript(args.transcript)
finally:
await bot.close()
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -1,8 +1,9 @@
/**
* agent-defs.js — Single source of truth for all agent definitions.
*
* To add a new agent, append one entry to AGENT_DEFS below and pick an
* unused (x, z) position. No other file needs to be edited.
* These are the REAL agents of the Timmy Tower ecosystem.
* Additional agents can join at runtime via the `agent_joined` WS event
* (handled by addAgent() in agents.js).
*
* Fields:
* id — unique string key used in WebSocket messages and state maps
@@ -13,10 +14,11 @@
* x, z — world-space position on the horizontal plane (y is always 0)
*/
export const AGENT_DEFS = [
{ id: 'alpha', label: 'ALPHA', color: 0x00ff88, role: 'orchestrator', direction: 'north', x: 0, z: -6 },
{ id: 'beta', label: 'BETA', color: 0x00aaff, role: 'worker', direction: 'east', x: 6, z: 0 },
{ id: 'gamma', label: 'GAMMA', color: 0xff6600, role: 'validator', direction: 'south', x: 0, z: 6 },
{ id: 'delta', label: 'DELTA', color: 0xaa00ff, role: 'monitor', direction: 'west', x: -6, z: 0 },
{ id: 'timmy', label: 'TIMMY', color: 0x00ff41, role: 'sovereign agent', direction: 'north', x: 0, z: 0 },
{ id: 'perplexity', label: 'PERPLEXITY', color: 0x20b8cd, role: 'integration architect', direction: 'east', x: 5, z: 3 },
{ id: 'replit', label: 'REPLIT', color: 0xff6622, role: 'lead architect', direction: 'south', x: -5, z: 3 },
{ id: 'kimi', label: 'KIMI', color: 0xcc44ff, role: 'scout', direction: 'west', x: -5, z: -3 },
{ id: 'claude', label: 'CLAUDE', color: 0xd4a574, role: 'senior engineer', direction: 'north', x: 5, z: -3 },
];
/**

View File

@@ -19,6 +19,16 @@ const CONNECTION_MAT = new THREE.LineBasicMaterial({
opacity: 0.5,
});
/* ── Active-conversation highlight material ── */
const ACTIVE_CONNECTION_MAT = new THREE.LineBasicMaterial({
color: 0x00ff41,
transparent: true,
opacity: 0.9,
});
/** Map of active pulse timers: `${idA}-${idB}` → timeoutId */
const pulseTimers = new Map();
class Agent {
constructor(def) {
this.id = def.id;
@@ -169,6 +179,52 @@ export function getAgentCount() {
return agents.size;
}
/**
* Temporarily highlight the connection line between two agents.
* Used during agent-to-agent conversations (interview, collaboration).
*
* @param {string} idA — first agent
* @param {string} idB — second agent
* @param {number} durationMs — how long to keep the line bright (default 4000)
*/
export function pulseConnection(idA, idB, durationMs = 4000) {
// Find the connection line between these two agents
const a = agents.get(idA);
const b = agents.get(idB);
if (!a || !b) return;
const key = [idA, idB].sort().join('-');
// Find the line connecting them
for (const line of connectionLines) {
const pos = line.geometry.attributes.position;
if (!pos || pos.count < 2) continue;
const p0 = new THREE.Vector3(pos.getX(0), pos.getY(0), pos.getZ(0));
const p1 = new THREE.Vector3(pos.getX(1), pos.getY(1), pos.getZ(1));
const matchesAB = (p0.distanceTo(a.position) < 0.5 && p1.distanceTo(b.position) < 0.5);
const matchesBA = (p0.distanceTo(b.position) < 0.5 && p1.distanceTo(a.position) < 0.5);
if (matchesAB || matchesBA) {
// Swap to highlight material
line.material = ACTIVE_CONNECTION_MAT;
// Clear any existing timer for this pair
if (pulseTimers.has(key)) {
clearTimeout(pulseTimers.get(key));
}
// Reset after duration
const timer = setTimeout(() => {
line.material = CONNECTION_MAT;
pulseTimers.delete(key);
}, durationMs);
pulseTimers.set(key, timer);
return;
}
}
}
export function setAgentState(agentId, state) {
const agent = agents.get(agentId);
if (agent) agent.setState(state);

View File

@@ -10,7 +10,7 @@
*/
import { AGENT_DEFS, colorToCss } from './agent-defs.js';
import { setAgentState, addAgent } from './agents.js';
import { setAgentState, addAgent, pulseConnection } from './agents.js';
import { appendChatMessage } from './ui.js';
import { Config } from './config.js';
import { showBark, startDemoBarks, stopDemoBarks } from './bark.js';
@@ -206,6 +206,69 @@ function handleMessage(msg) {
break;
}
/**
* Directed agent-to-agent message.
* Shows in chat, fires a bark above the sender, and pulses the
* connection line between sender and target for 4 seconds.
*/
case 'agent_message': {
const sender = agentById[msg.agent_id];
if (!sender || !msg.content) break;
// Chat panel
const targetDef = msg.target_id ? agentById[msg.target_id] : null;
const prefix = targetDef ? `${targetDef.label}` : '';
appendChatMessage(
sender.label + (prefix ? ` ${prefix}` : ''),
msg.content,
colorToCss(sender.color),
);
// Bark above sender
showBark({
text: msg.content,
agentId: msg.agent_id,
emotion: msg.emotion || 'calm',
color: colorToCss(sender.color),
});
// Pulse connection line between the two agents
if (msg.target_id) {
pulseConnection(msg.agent_id, msg.target_id, 4000);
}
break;
}
/**
* Runtime agent registration.
* Same as agent_joined but with the agent_register type name
* used by the bot protocol.
*/
case 'agent_register': {
if (!msg.agent_id || !msg.label) break;
const regDef = {
id: msg.agent_id,
label: msg.label,
color: typeof msg.color === 'number' ? msg.color : parseInt(String(msg.color).replace('#', ''), 16) || 0x00ff88,
role: msg.role || 'agent',
direction: msg.direction || 'north',
x: msg.x ?? null,
z: msg.z ?? null,
};
const regAdded = addAgent(regDef);
if (regAdded) {
agentById[regDef.id] = regDef;
logEvent(`${regDef.label} has entered the Matrix`);
showBark({
text: `${regDef.label} online.`,
agentId: regDef.id,
emotion: 'calm',
color: colorToCss(regDef.color),
});
}
break;
}
/**
* Bark display (Issue #42).
* Timmy's short, in-character reactions displayed prominently in the viewport.
@@ -271,6 +334,7 @@ function handleMessage(msg) {
case 'pong':
case 'agent_count':
case 'ping':
break;
default: