Skip to content

Commit c3a0602

Browse files
author
VoardWalker-Code
committed
feat: server-side slash command interceptor
- Created server/routes/slash-interceptor.js — intercepts /command messages before LLM pipeline so slash commands work from ALL clients - Supported commands: /task, /project, /skill, /websearch, /list, /listactive, /stop - Wired into /api/chat (entity chat) and /api/nekocore/chat (NekoCore OS chat) - Failsafe console gets slash support automatically via /api/chat - Registered in CORE_REGISTRY (301 entries) - Tests: 2012/2012 (0 fail)
1 parent 09a6834 commit c3a0602

4 files changed

Lines changed: 252 additions & 1 deletion

File tree

project/scripts/health-scan.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ const CORE_REGISTRY = {
5252
'server/routes/memory-routes.js': 'Memory API routes',
5353
'server/routes/nekocore-routes.js': 'NekoCore system routes',
5454
'server/routes/skills-routes.js': 'Skills API routes',
55+
'server/routes/slash-interceptor.js': 'Server-side slash command interceptor',
5556
'server/routes/sse-routes.js': 'Server-sent events routes',
5657
'server/routes/task-routes.js': 'Task API routes',
5758
'server/routes/vfs-routes.js': 'Virtual filesystem routes',

project/server/routes/chat-routes.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
function createChatRoutes(ctx) {
66
const { enforceResponseContract } = require('../contracts/response-contracts');
7+
const slashInterceptor = require('./slash-interceptor');
78
const logTimeline = (type, payload = {}) => {
89
try {
910
if (ctx.timelineLogger && typeof ctx.timelineLogger.logEvent === 'function') {
@@ -57,6 +58,17 @@ function createChatRoutes(ctx) {
5758
return;
5859
}
5960

61+
// ── Slash command intercept — dispatch before LLM pipeline ──
62+
const entityId = ctx.getActiveEntityId ? ctx.getActiveEntityId() : null;
63+
const slash = await slashInterceptor.intercept(userMessage, entityId, ctx);
64+
if (slash.handled) {
65+
const validatedSlash = enforceResponseContract('/api/chat', slash.response);
66+
res.writeHead(200, apiHeaders);
67+
res.end(JSON.stringify(validatedSlash));
68+
logTimeline('api.chat.slash_command', { command: userMessage.split(/\s+/)[0] });
69+
return;
70+
}
71+
6072
// Route single-llm entities to the simplified pipeline
6173
let result;
6274
let isSingleLlm = false;

project/server/routes/nekocore-routes.js

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -630,8 +630,20 @@ function createNekoCoreRoutes(ctx) {
630630
res.end(JSON.stringify({ error: 'Missing message' }));
631631
return;
632632
}
633+
const trimmed = String(message).trim();
634+
635+
// ── Slash command intercept — dispatch before LLM pipeline ──
636+
const slashInterceptor = require('./slash-interceptor');
637+
const nekoCoreEntityId = 'nekocore';
638+
const slash = await slashInterceptor.intercept(trimmed, nekoCoreEntityId, ctx);
639+
if (slash.handled) {
640+
res.writeHead(200, apiHeaders);
641+
res.end(JSON.stringify(slash.response));
642+
return;
643+
}
644+
633645
const result = await ctx.processNekoCoreChatMessage(
634-
String(message).trim(),
646+
trimmed,
635647
Array.isArray(chatHistory) ? chatHistory : []
636648
);
637649
res.writeHead(200, apiHeaders);
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
// ── Server-Side Slash Command Interceptor ────────────────────────────────────
2+
// Detects /command messages before they reach the LLM pipeline.
3+
// Used by both /api/chat and /api/nekocore/chat so slash commands work
4+
// from every client: entity chat, NekoCore OS chat, and failsafe console.
5+
//
6+
// Returns { handled, response } where:
7+
// handled: true → caller should return `response` to the client
8+
// handled: false → message is not a slash command, proceed to LLM
9+
// ─────────────────────────────────────────────────────────────────────────────
10+
11+
'use strict';
12+
13+
const SLASH_RE = /^\/([a-z]+)(?:\s+(.*))?$/i;
14+
15+
const taskSession = require('../brain/tasks/task-session');
16+
const taskExecutor = require('../brain/tasks/task-executor');
17+
const taskEventBus = require('../brain/tasks/task-event-bus');
18+
const { classify } = require('../brain/tasks/intent-classifier');
19+
const { gatherContext } = require('../brain/tasks/task-context-gatherer');
20+
const taskModuleRegistry = require('../brain/tasks/task-module-registry');
21+
const projectStore = require('../brain/tasks/task-project-store');
22+
const archiveWriter = require('../brain/tasks/task-archive-writer');
23+
const workspaceTools = require('../brain/workspace-tools');
24+
25+
function _readAllSessionsForHistory() {
26+
const fs = require('fs');
27+
const path = require('path');
28+
const filePath = path.join(__dirname, '..', 'data', 'task-sessions.json');
29+
if (!fs.existsSync(filePath)) return [];
30+
try {
31+
return Object.values(JSON.parse(fs.readFileSync(filePath, 'utf8')) || {});
32+
} catch (_) { return []; }
33+
}
34+
35+
/**
36+
* Try to intercept a slash command.
37+
* @param {string} message — the raw user message (already trimmed)
38+
* @param {string} entityId — resolved entity ID (active entity or 'nekocore')
39+
* @param {object} ctx — server context (callLLMWithRuntime, broadcastSSE, webFetch)
40+
* @returns {{ handled: boolean, response?: object }}
41+
*/
42+
async function intercept(message, entityId, ctx) {
43+
if (!message || message[0] !== '/') return { handled: false };
44+
45+
const m = message.match(SLASH_RE);
46+
if (!m) return { handled: false };
47+
48+
const cmd = m[1].toLowerCase();
49+
const args = (m[2] || '').trim();
50+
51+
switch (cmd) {
52+
case 'task': return _dispatchTask(args, entityId, ctx, false);
53+
case 'project': return _dispatchTask(args, entityId, ctx, true);
54+
case 'skill': return _dispatchSkill(args, entityId, ctx);
55+
case 'websearch': return _dispatchWebSearch(args, entityId, ctx);
56+
case 'stop': return _cmdStop(args);
57+
case 'list': return _cmdList(entityId);
58+
case 'listactive': return _cmdListActive(entityId);
59+
default: return { handled: false }; // unknown → pass to LLM
60+
}
61+
}
62+
63+
// ── /task and /project ──────────────────────────────────────────────────────
64+
async function _dispatchTask(description, entityId, ctx, isProject) {
65+
if (!description) {
66+
return _ok(`Usage: /${isProject ? 'project' : 'task'} <description>`);
67+
}
68+
if (!entityId) return _ok('⚠️ No active entity. Load an entity first.');
69+
70+
const taskType = isProject ? 'project' : null;
71+
return _runTask(description, entityId, ctx, taskType);
72+
}
73+
74+
// ── /skill ──────────────────────────────────────────────────────────────────
75+
async function _dispatchSkill(args, entityId, ctx) {
76+
const parts = args.split(/\s+/);
77+
const skillName = parts[0];
78+
if (!skillName) return _ok('Usage: /skill <name> [args…]');
79+
if (!entityId) return _ok('⚠️ No active entity. Load an entity first.');
80+
81+
const skillArgs = parts.slice(1).join(' ');
82+
return _runTask(skillArgs || skillName, entityId, ctx, 'skill', skillName);
83+
}
84+
85+
// ── /websearch ──────────────────────────────────────────────────────────────
86+
async function _dispatchWebSearch(query, entityId, ctx) {
87+
if (!query) return _ok('Usage: /websearch <query>');
88+
if (!entityId) return _ok('⚠️ No active entity. Load an entity first.');
89+
return _runTask(query, entityId, ctx, 'research');
90+
}
91+
92+
// ── /stop ───────────────────────────────────────────────────────────────────
93+
function _cmdStop(sessionId) {
94+
if (!sessionId) return _ok('Usage: /stop <session-id>');
95+
const closed = taskSession.closeSession(sessionId, 'cancelled', {});
96+
if (!closed) return _ok(`⚠️ Session "${sessionId}" not found.`);
97+
return _ok(`✋ Task ${sessionId} cancelled.`);
98+
}
99+
100+
// ── /list ───────────────────────────────────────────────────────────────────
101+
function _cmdList(entityId) {
102+
if (!entityId) return _ok('⚠️ No active entity.');
103+
const sessions = _readAllSessionsForHistory()
104+
.filter(s => String(s.entityId) === String(entityId))
105+
.sort((a, b) => Number(b.updatedAt || 0) - Number(a.updatedAt || 0))
106+
.slice(0, 20);
107+
if (!sessions.length) return _ok('No task history found.');
108+
const lines = sessions.map(s =>
109+
`• [${s.id}] ${s.taskType || '—'} ${s.status || '—'}`
110+
).join('\n');
111+
return _ok('Task history:\n' + lines);
112+
}
113+
114+
// ── /listactive ─────────────────────────────────────────────────────────────
115+
function _cmdListActive(entityId) {
116+
if (!entityId) return _ok('⚠️ No active entity.');
117+
const sessions = _readAllSessionsForHistory()
118+
.filter(s =>
119+
String(s.entityId) === String(entityId) &&
120+
(s.status === 'running' || s.status === 'pending')
121+
);
122+
if (!sessions.length) return _ok('No active tasks running.');
123+
const lines = sessions.map(s =>
124+
`• [${s.id}] ${s.taskType || '—'} ${s.status}`
125+
).join('\n');
126+
return _ok('Active tasks:\n' + lines);
127+
}
128+
129+
// ── Shared task dispatch ────────────────────────────────────────────────────
130+
async function _runTask(userMessage, entityId, ctx, taskType, skill) {
131+
try {
132+
const classification = taskType
133+
? { intent: 'task', taskType, confidence: 1, method: 'slash-command' }
134+
: await classify(userMessage, { llmFallback: false });
135+
136+
if (classification.intent !== 'task' || !classification.taskType) {
137+
return _ok('Could not classify task — try being more specific.');
138+
}
139+
140+
const moduleConfig = taskModuleRegistry.getModule(classification.taskType);
141+
if (!moduleConfig) {
142+
return _ok(`⚠️ Unsupported task type: ${classification.taskType}`);
143+
}
144+
145+
const project = projectStore.resolveOrCreateProject(entityId, classification.taskType, userMessage);
146+
const taskId = 'task_' + Date.now();
147+
const taskArchiveId = archiveWriter.createTaskArchive(project.id, taskId, {
148+
userMessage,
149+
taskType: classification.taskType
150+
}, { entityId });
151+
152+
const session = taskSession.createSession({
153+
entityId,
154+
taskType: classification.taskType,
155+
projectId: project.id,
156+
taskArchiveId,
157+
sharedContext: { userMessage, classification }
158+
}, {});
159+
160+
const context = await gatherContext(classification.taskType, userMessage, { id: entityId });
161+
taskSession.updateSession(session.id, {
162+
sharedContext: { taskContext: context }
163+
}, {});
164+
165+
const runConfig = {
166+
taskType: classification.taskType,
167+
userMessage,
168+
entity: { id: entityId, name: 'Entity', persona: null, mood: null, relationship: null, workspacePath: '' },
169+
contextSnippets: context.snippets || [],
170+
callLLM: ctx.callLLMWithRuntime,
171+
runtime: {},
172+
allTools: { workspaceTools, webFetch: ctx.webFetch },
173+
taskArchiveId,
174+
archiveWriter,
175+
...(skill ? { skill } : {})
176+
};
177+
178+
// Subscribe to task events for session tracking
179+
const handler = (event) => {
180+
if (!event || !event.type) return;
181+
if (event.type === 'milestone') {
182+
taskSession.appendStep(session.id, {
183+
stepIndex: event.stepIndex, stepTotal: event.stepTotal,
184+
description: event.stepDescription, summary: event.stepSummary,
185+
taskType: event.taskType, timestamp: event.timestamp
186+
}, {});
187+
} else if (event.type === 'task_complete') {
188+
taskSession.closeSession(session.id, 'complete', {});
189+
} else if (event.type === 'task_error') {
190+
taskSession.closeSession(session.id, 'cancelled', {});
191+
}
192+
};
193+
taskEventBus.subscribe(session.id, handler);
194+
195+
// Fire-and-forget async execution
196+
setImmediate(async () => {
197+
try {
198+
const result = await taskExecutor.executeTask(runConfig);
199+
taskSession.updateSession(session.id, {
200+
sharedContext: { finalOutput: result.finalOutput, completedAt: result.completedAt }
201+
}, {});
202+
} catch (e) {
203+
taskSession.updateSession(session.id, {
204+
sharedContext: { lastError: e.message }
205+
}, {});
206+
} finally {
207+
taskEventBus.unsubscribe(session.id, handler);
208+
}
209+
});
210+
211+
const label = classification.taskType.charAt(0).toUpperCase() + classification.taskType.slice(1);
212+
return _ok(
213+
`✅ ${label} task started (session: ${session.id}).\n` +
214+
`Use /listactive to check progress, or /stop ${session.id} to cancel.`
215+
);
216+
} catch (e) {
217+
return _ok('⚠️ Task dispatch failed: ' + e.message);
218+
}
219+
}
220+
221+
// ── Helpers ─────────────────────────────────────────────────────────────────
222+
function _ok(text) {
223+
return { handled: true, response: { ok: true, response: text, slashCommand: true } };
224+
}
225+
226+
module.exports = { intercept };

0 commit comments

Comments
 (0)