Compare commits

..

19 Commits

Author SHA1 Message Date
gavrielc 12ab5a40b5 merge: catch up with upstream main
Picks up main's changes while preserving /compact session commands:
- Built-in logger replacing pino/pino-pretty
- Removed unused deps (yaml, zod, @vitest/coverage-v8)
- Task script phase in agent runner (kept alongside slash commands)
- Updated TRIGGER_PATTERN to per-group getTriggerPattern()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 15:57:19 +03:00
gavrielc fa1fe39bb3 chore: remove direct pino/pino-pretty dependency
Pino was replaced with a built-in logger on main. For branches
with baileys (WhatsApp), pino resolves as a transitive dependency
of @whiskeysockets/baileys.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 22:39:46 +03:00
github-actions[bot] 614278187d Merge branch 'main' into skill/compact 2026-03-25 11:26:27 +00:00
github-actions[bot] 832a9aa049 Merge branch 'main' into skill/compact 2026-03-25 11:26:15 +00:00
github-actions[bot] 2906a0ec10 Merge branch 'main' into skill/compact 2026-03-24 23:05:34 +00:00
github-actions[bot] 540db101bc Merge branch 'main' into skill/compact 2026-03-24 23:05:16 +00:00
github-actions[bot] 44da74b6c5 Merge branch 'main' into skill/compact 2026-03-14 15:24:19 +00:00
github-actions[bot] c0acbfebbf Merge branch 'main' into skill/compact 2026-03-14 13:17:01 +00:00
github-actions[bot] b2bfe92598 Merge branch 'main' into skill/compact 2026-03-13 11:59:45 +00:00
github-actions[bot] 3014194ed4 Merge branch 'main' into skill/compact 2026-03-13 11:59:13 +00:00
github-actions[bot] e0401a519f Merge branch 'main' into skill/compact 2026-03-11 10:30:47 +00:00
github-actions[bot] 20ba7a0b91 Merge branch 'main' into skill/compact 2026-03-11 10:25:43 +00:00
github-actions[bot] a64551a8f3 Merge branch 'main' into skill/compact 2026-03-10 20:59:46 +00:00
github-actions[bot] f59c863c95 Merge branch 'main' into skill/compact 2026-03-10 20:52:10 +00:00
github-actions[bot] 6833d76c74 Merge branch 'main' into skill/compact 2026-03-10 20:40:03 +00:00
github-actions[bot] de69b8c6b2 Merge branch 'main' into skill/compact 2026-03-10 00:25:43 +00:00
gavrielc 07c03cc148 Merge commit '5f9774d' into rebuild-fork 2026-03-10 01:15:26 +02:00
gavrielc 5f9774df55 Merge remote-tracking branch 'origin/main' into skill/compact 2026-03-09 23:20:51 +02:00
gavrielc 9558bdfcdd skill/compact: /compact session command for context compaction
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 00:09:07 +02:00
9 changed files with 512 additions and 354 deletions
-1
View File
@@ -1 +0,0 @@
OLLAMA_HOST=
+101 -6
View File
@@ -409,8 +409,7 @@ async function runQuery(
'TeamCreate', 'TeamDelete', 'SendMessage',
'TodoWrite', 'ToolSearch', 'Skill',
'NotebookEdit',
'mcp__nanoclaw__*',
'mcp__ollama__*'
'mcp__nanoclaw__*'
],
env: sdkEnv,
permissionMode: 'bypassPermissions',
@@ -426,10 +425,6 @@ async function runQuery(
NANOCLAW_IS_MAIN: containerInput.isMain ? '1' : '0',
},
},
ollama: {
command: 'node',
args: [path.join(path.dirname(mcpServerPath), 'ollama-mcp-stdio.js')],
},
},
hooks: {
PreCompact: [{ hooks: [createPreCompactHook(containerInput.assistantName)] }],
@@ -561,6 +556,106 @@ async function main(): Promise<void> {
prompt += '\n' + pending.join('\n');
}
// --- Slash command handling ---
// Only known session slash commands are handled here. This prevents
// accidental interception of user prompts that happen to start with '/'.
const KNOWN_SESSION_COMMANDS = new Set(['/compact']);
const trimmedPrompt = prompt.trim();
const isSessionSlashCommand = KNOWN_SESSION_COMMANDS.has(trimmedPrompt);
if (isSessionSlashCommand) {
log(`Handling session command: ${trimmedPrompt}`);
let slashSessionId: string | undefined;
let compactBoundarySeen = false;
let hadError = false;
let resultEmitted = false;
try {
for await (const message of query({
prompt: trimmedPrompt,
options: {
cwd: '/workspace/group',
resume: sessionId,
systemPrompt: undefined,
allowedTools: [],
env: sdkEnv,
permissionMode: 'bypassPermissions' as const,
allowDangerouslySkipPermissions: true,
settingSources: ['project', 'user'] as const,
hooks: {
PreCompact: [{ hooks: [createPreCompactHook(containerInput.assistantName)] }],
},
},
})) {
const msgType = message.type === 'system'
? `system/${(message as { subtype?: string }).subtype}`
: message.type;
log(`[slash-cmd] type=${msgType}`);
if (message.type === 'system' && message.subtype === 'init') {
slashSessionId = message.session_id;
log(`Session after slash command: ${slashSessionId}`);
}
// Observe compact_boundary to confirm compaction completed
if (message.type === 'system' && (message as { subtype?: string }).subtype === 'compact_boundary') {
compactBoundarySeen = true;
log('Compact boundary observed — compaction completed');
}
if (message.type === 'result') {
const resultSubtype = (message as { subtype?: string }).subtype;
const textResult = 'result' in message ? (message as { result?: string }).result : null;
if (resultSubtype?.startsWith('error')) {
hadError = true;
writeOutput({
status: 'error',
result: null,
error: textResult || 'Session command failed.',
newSessionId: slashSessionId,
});
} else {
writeOutput({
status: 'success',
result: textResult || 'Conversation compacted.',
newSessionId: slashSessionId,
});
}
resultEmitted = true;
}
}
} catch (err) {
hadError = true;
const errorMsg = err instanceof Error ? err.message : String(err);
log(`Slash command error: ${errorMsg}`);
writeOutput({ status: 'error', result: null, error: errorMsg });
}
log(`Slash command done. compactBoundarySeen=${compactBoundarySeen}, hadError=${hadError}`);
// Warn if compact_boundary was never observed — compaction may not have occurred
if (!hadError && !compactBoundarySeen) {
log('WARNING: compact_boundary was not observed. Compaction may not have completed.');
}
// Only emit final session marker if no result was emitted yet and no error occurred
if (!resultEmitted && !hadError) {
writeOutput({
status: 'success',
result: compactBoundarySeen
? 'Conversation compacted.'
: 'Compaction requested but compact_boundary was not observed.',
newSessionId: slashSessionId,
});
} else if (!hadError) {
// Emit session-only marker so host updates session tracking
writeOutput({ status: 'success', result: null, newSessionId: slashSessionId });
}
return;
}
// --- End slash command handling ---
// Script phase: run script before waking agent
if (containerInput.script && containerInput.isScheduledTask) {
log('Running task script...');
@@ -1,290 +0,0 @@
/**
* Ollama MCP Server for NanoClaw
* Exposes local Ollama models as tools for the container agent.
* Uses host.docker.internal to reach the host's Ollama instance from Docker.
*/
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
import fs from 'fs';
import path from 'path';
const OLLAMA_HOST = process.env.OLLAMA_HOST || 'http://host.docker.internal:11434';
const OLLAMA_ADMIN_TOOLS = process.env.OLLAMA_ADMIN_TOOLS === 'true';
const OLLAMA_STATUS_FILE = '/workspace/ipc/ollama_status.json';
function log(msg: string): void {
console.error(`[OLLAMA] ${msg}`);
}
function writeStatus(status: string, detail?: string): void {
try {
const data = { status, detail, timestamp: new Date().toISOString() };
const tmpPath = `${OLLAMA_STATUS_FILE}.tmp`;
fs.mkdirSync(path.dirname(OLLAMA_STATUS_FILE), { recursive: true });
fs.writeFileSync(tmpPath, JSON.stringify(data));
fs.renameSync(tmpPath, OLLAMA_STATUS_FILE);
} catch { /* best-effort */ }
}
async function ollamaFetch(path: string, options?: RequestInit): Promise<Response> {
const url = `${OLLAMA_HOST}${path}`;
try {
return await fetch(url, options);
} catch (err) {
// Fallback to localhost if host.docker.internal fails
if (OLLAMA_HOST.includes('host.docker.internal')) {
const fallbackUrl = url.replace('host.docker.internal', 'localhost');
return await fetch(fallbackUrl, options);
}
throw err;
}
}
const server = new McpServer({
name: 'ollama',
version: '1.0.0',
});
server.tool(
'ollama_list_models',
'List all locally installed Ollama models. Use this to see which models are available before calling ollama_generate.',
{},
async () => {
log('Listing models...');
writeStatus('listing', 'Listing available models');
try {
const res = await ollamaFetch('/api/tags');
if (!res.ok) {
return {
content: [{ type: 'text' as const, text: `Ollama API error: ${res.status} ${res.statusText}` }],
isError: true,
};
}
const data = await res.json() as { models?: Array<{ name: string; size: number; modified_at: string }> };
const models = data.models || [];
if (models.length === 0) {
return { content: [{ type: 'text' as const, text: 'No models installed. Run `ollama pull <model>` on the host to install one.' }] };
}
const list = models
.map(m => `- ${m.name} (${(m.size / 1e9).toFixed(1)}GB)`)
.join('\n');
log(`Found ${models.length} models`);
return { content: [{ type: 'text' as const, text: `Installed models:\n${list}` }] };
} catch (err) {
return {
content: [{ type: 'text' as const, text: `Failed to connect to Ollama at ${OLLAMA_HOST}: ${err instanceof Error ? err.message : String(err)}` }],
isError: true,
};
}
},
);
server.tool(
'ollama_generate',
'Send a prompt to a local Ollama model and get a response. Good for cheaper/faster tasks like summarization, translation, or general queries. Use ollama_list_models first to see available models.',
{
model: z.string().describe('The model name (e.g., "llama3.2", "mistral", "gemma2")'),
prompt: z.string().describe('The prompt to send to the model'),
system: z.string().optional().describe('Optional system prompt to set model behavior'),
},
async (args) => {
log(`>>> Generating with ${args.model} (${args.prompt.length} chars)...`);
writeStatus('generating', `Generating with ${args.model}`);
try {
const body: Record<string, unknown> = {
model: args.model,
prompt: args.prompt,
stream: false,
};
if (args.system) {
body.system = args.system;
}
const res = await ollamaFetch('/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const errorText = await res.text();
return {
content: [{ type: 'text' as const, text: `Ollama error (${res.status}): ${errorText}` }],
isError: true,
};
}
const data = await res.json() as { response: string; total_duration?: number; eval_count?: number };
let meta = '';
if (data.total_duration) {
const secs = (data.total_duration / 1e9).toFixed(1);
meta = `\n\n[${args.model} | ${secs}s${data.eval_count ? ` | ${data.eval_count} tokens` : ''}]`;
log(`<<< Done: ${args.model} | ${secs}s | ${data.eval_count || '?'} tokens | ${data.response.length} chars`);
writeStatus('done', `${args.model} | ${secs}s | ${data.eval_count || '?'} tokens`);
} else {
log(`<<< Done: ${args.model} | ${data.response.length} chars`);
writeStatus('done', `${args.model} | ${data.response.length} chars`);
}
return { content: [{ type: 'text' as const, text: data.response + meta }] };
} catch (err) {
return {
content: [{ type: 'text' as const, text: `Failed to call Ollama: ${err instanceof Error ? err.message : String(err)}` }],
isError: true,
};
}
},
);
// Management tools — only registered when OLLAMA_ADMIN_TOOLS=true
if (OLLAMA_ADMIN_TOOLS) {
server.tool(
'ollama_pull_model',
'Pull (download) a model from the Ollama registry by name. Returns the final status once the pull is complete. Use model names like "llama3.2", "mistral", "gemma2:9b".',
{
model: z.string().describe('Model name to pull, e.g. "llama3.2", "mistral", "gemma2:9b"'),
},
async (args) => {
log(`Pulling model: ${args.model}...`);
writeStatus('pulling', `Pulling ${args.model}`);
try {
const res = await ollamaFetch('/api/pull', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: args.model, stream: false }),
});
if (!res.ok) {
const errorText = await res.text();
return {
content: [{ type: 'text' as const, text: `Ollama error (${res.status}): ${errorText}` }],
isError: true,
};
}
const data = await res.json() as { status: string };
log(`Pull complete: ${args.model}${data.status}`);
writeStatus('done', `Pulled ${args.model}`);
return { content: [{ type: 'text' as const, text: `Pull complete: ${args.model}${data.status}` }] };
} catch (err) {
return {
content: [{ type: 'text' as const, text: `Failed to pull model: ${err instanceof Error ? err.message : String(err)}` }],
isError: true,
};
}
},
);
server.tool(
'ollama_delete_model',
'Delete a locally installed Ollama model to free up disk space.',
{
model: z.string().describe('Model name to delete, e.g. "llama3.2", "mistral:latest"'),
},
async (args) => {
log(`Deleting model: ${args.model}...`);
writeStatus('deleting', `Deleting ${args.model}`);
try {
const res = await ollamaFetch('/api/delete', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: args.model }),
});
if (!res.ok) {
const errorText = await res.text();
return {
content: [{ type: 'text' as const, text: `Ollama error (${res.status}): ${errorText}` }],
isError: true,
};
}
log(`Deleted: ${args.model}`);
writeStatus('done', `Deleted ${args.model}`);
return { content: [{ type: 'text' as const, text: `Deleted model: ${args.model}` }] };
} catch (err) {
return {
content: [{ type: 'text' as const, text: `Failed to delete model: ${err instanceof Error ? err.message : String(err)}` }],
isError: true,
};
}
},
);
server.tool(
'ollama_show_model',
'Show details for a locally installed Ollama model: modelfile, parameters, template, system prompt, and architecture info.',
{
model: z.string().describe('Model name to inspect, e.g. "llama3.2", "mistral:latest"'),
},
async (args) => {
log(`Showing model info: ${args.model}...`);
try {
const res = await ollamaFetch('/api/show', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: args.model }),
});
if (!res.ok) {
const errorText = await res.text();
return {
content: [{ type: 'text' as const, text: `Ollama error (${res.status}): ${errorText}` }],
isError: true,
};
}
const data = await res.json();
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
} catch (err) {
return {
content: [{ type: 'text' as const, text: `Failed to show model info: ${err instanceof Error ? err.message : String(err)}` }],
isError: true,
};
}
},
);
server.tool(
'ollama_list_running',
'List Ollama models currently loaded in memory with their memory usage, processor type (CPU/GPU), and time until they are unloaded.',
{},
async () => {
log('Listing running models...');
try {
const res = await ollamaFetch('/api/ps');
if (!res.ok) {
return {
content: [{ type: 'text' as const, text: `Ollama API error: ${res.status} ${res.statusText}` }],
isError: true,
};
}
const data = await res.json() as { models?: Array<{ name: string; size: number; size_vram: number; processor: string; expires_at: string }> };
const models = data.models || [];
if (models.length === 0) {
return { content: [{ type: 'text' as const, text: 'No models currently loaded in memory.' }] };
}
const list = models
.map(m => {
const size = m.size_vram > 0 ? m.size_vram : m.size;
return `- ${m.name} (${(size / 1e9).toFixed(1)}GB ${m.processor}, unloads at ${m.expires_at})`;
})
.join('\n');
log(`${models.length} model(s) running`);
return { content: [{ type: 'text' as const, text: `Models loaded in memory:\n${list}` }] };
} catch (err) {
return {
content: [{ type: 'text' as const, text: `Failed to list running models: ${err instanceof Error ? err.message : String(err)}` }],
isError: true,
};
}
},
);
log('Admin tools enabled (pull, delete, show, list-running)');
}
const transport = new StdioServerTransport();
await server.connect(transport);
-41
View File
@@ -1,41 +0,0 @@
#!/bin/bash
# Watch NanoClaw IPC for Ollama activity and show macOS notifications
# Usage: ./scripts/ollama-watch.sh
cd "$(dirname "$0")/.." || exit 1
echo "Watching for Ollama activity..."
echo "Press Ctrl+C to stop"
echo ""
LAST_TIMESTAMP=""
while true; do
# Check all group IPC dirs for ollama_status.json
for status_file in data/ipc/*/ollama_status.json; do
[ -f "$status_file" ] || continue
TIMESTAMP=$(python3 -c "import json; print(json.load(open('$status_file'))['timestamp'])" 2>/dev/null)
[ -z "$TIMESTAMP" ] && continue
[ "$TIMESTAMP" = "$LAST_TIMESTAMP" ] && continue
LAST_TIMESTAMP="$TIMESTAMP"
STATUS=$(python3 -c "import json; d=json.load(open('$status_file')); print(d['status'])" 2>/dev/null)
DETAIL=$(python3 -c "import json; d=json.load(open('$status_file')); print(d.get('detail',''))" 2>/dev/null)
case "$STATUS" in
generating)
osascript -e "display notification \"$DETAIL\" with title \"NanoClaw → Ollama\" sound name \"Submarine\"" 2>/dev/null
echo "$(date +%H:%M:%S) 🔄 $DETAIL"
;;
done)
osascript -e "display notification \"$DETAIL\" with title \"NanoClaw ← Ollama ✓\" sound name \"Glass\"" 2>/dev/null
echo "$(date +%H:%M:%S)$DETAIL"
;;
listing)
echo "$(date +%H:%M:%S) 📋 Listing models..."
;;
esac
done
sleep 0.5
done
-3
View File
@@ -8,7 +8,6 @@ import { isValidTimezone } from './timezone.js';
const envConfig = readEnvFile([
'ASSISTANT_NAME',
'ASSISTANT_HAS_OWN_NUMBER',
'OLLAMA_ADMIN_TOOLS',
'ONECLI_URL',
'TZ',
]);
@@ -18,8 +17,6 @@ export const ASSISTANT_NAME =
export const ASSISTANT_HAS_OWN_NUMBER =
(process.env.ASSISTANT_HAS_OWN_NUMBER ||
envConfig.ASSISTANT_HAS_OWN_NUMBER) === 'true';
export const OLLAMA_ADMIN_TOOLS =
(process.env.OLLAMA_ADMIN_TOOLS || envConfig.OLLAMA_ADMIN_TOOLS) === 'true';
export const POLL_INTERVAL = 2000;
export const SCHEDULER_POLL_INTERVAL = 60000;
+1 -12
View File
@@ -13,7 +13,6 @@ import {
DATA_DIR,
GROUPS_DIR,
IDLE_TIMEOUT,
OLLAMA_ADMIN_TOOLS,
ONECLI_URL,
TIMEZONE,
} from './config.js';
@@ -234,11 +233,6 @@ async function buildContainerArgs(
// Pass host timezone so container's local time matches the user's
args.push('-e', `TZ=${TIMEZONE}`);
// Forward Ollama admin tools flag if enabled
if (OLLAMA_ADMIN_TOOLS) {
args.push('-e', 'OLLAMA_ADMIN_TOOLS=true');
}
// OneCLI gateway handles credential injection — containers never see real secrets.
// The gateway intercepts HTTPS traffic and injects API keys or OAuth tokens.
const onecliApplied = await onecli.applyContainerConfig(args, {
@@ -406,12 +400,7 @@ export async function runContainerAgent(
const chunk = data.toString();
const lines = chunk.trim().split('\n');
for (const line of lines) {
if (!line) continue;
if (line.includes('[OLLAMA]')) {
logger.info({ container: group.folder }, line);
} else {
logger.debug({ container: group.folder }, line);
}
if (line) logger.debug({ container: group.folder }, line);
}
// Don't reset timeout on stderr — SDK writes debug logs continuously.
// Timeout only resets on actual output (OUTPUT_MARKER in stdout).
+53 -1
View File
@@ -60,6 +60,7 @@ import {
loadSenderAllowlist,
shouldDropMessage,
} from './sender-allowlist.js';
import { extractSessionCommand, handleSessionCommand, isSessionCommandAllowed } from './session-commands.js';
import { startSchedulerLoop } from './task-scheduler.js';
import { Channel, NewMessage, RegisteredGroup } from './types.js';
import { logger } from './logger.js';
@@ -237,6 +238,33 @@ async function processGroupMessages(chatJid: string): Promise<boolean> {
if (missedMessages.length === 0) return true;
// --- Session command interception (before trigger check) ---
const cmdResult = await handleSessionCommand({
missedMessages,
isMainGroup,
groupName: group.name,
triggerPattern: getTriggerPattern(group.trigger),
timezone: TIMEZONE,
deps: {
sendMessage: (text) => channel.sendMessage(chatJid, text),
setTyping: (typing) => channel.setTyping?.(chatJid, typing) ?? Promise.resolve(),
runAgent: (prompt, onOutput) => runAgent(group, prompt, chatJid, onOutput),
closeStdin: () => queue.closeStdin(chatJid),
advanceCursor: (ts) => { lastAgentTimestamp[chatJid] = ts; saveState(); },
formatMessages,
canSenderInteract: (msg) => {
const hasTrigger = getTriggerPattern(group.trigger).test(msg.content.trim());
const reqTrigger = !isMainGroup && group.requiresTrigger !== false;
return isMainGroup || !reqTrigger || (hasTrigger && (
msg.is_from_me ||
isTriggerAllowed(chatJid, msg.sender, loadSenderAllowlist())
));
},
},
});
if (cmdResult.handled) return cmdResult.success;
// --- End session command interception ---
// For non-main groups, check if trigger is required and present
if (!isMainGroup && group.requiresTrigger !== false) {
const triggerPattern = getTriggerPattern(group.trigger);
@@ -246,7 +274,9 @@ async function processGroupMessages(chatJid: string): Promise<boolean> {
triggerPattern.test(m.content.trim()) &&
(m.is_from_me || isTriggerAllowed(chatJid, m.sender, allowlistCfg)),
);
if (!hasTrigger) return true;
if (!hasTrigger) {
return true;
}
}
const prompt = formatMessages(missedMessages, TIMEZONE);
@@ -463,6 +493,28 @@ async function startMessageLoop(): Promise<void> {
}
const isMainGroup = group.isMain === true;
// --- Session command interception (message loop) ---
// Scan ALL messages in the batch for a session command.
const loopCmdMsg = groupMessages.find(
(m) => extractSessionCommand(m.content, getTriggerPattern(group.trigger)) !== null,
);
if (loopCmdMsg) {
// Only close active container if the sender is authorized — otherwise an
// untrusted user could kill in-flight work by sending /compact (DoS).
// closeStdin no-ops internally when no container is active.
if (isSessionCommandAllowed(isMainGroup, loopCmdMsg.is_from_me === true)) {
queue.closeStdin(chatJid);
}
// Enqueue so processGroupMessages handles auth + cursor advancement.
// Don't pipe via IPC — slash commands need a fresh container with
// string prompt (not MessageStream) for SDK recognition.
queue.enqueueMessageCheck(chatJid);
continue;
}
// --- End session command interception ---
const needsTrigger = !isMainGroup && group.requiresTrigger !== false;
// For non-main groups, only act on trigger messages.
+214
View File
@@ -0,0 +1,214 @@
import { describe, it, expect, vi } from 'vitest';
import { extractSessionCommand, handleSessionCommand, isSessionCommandAllowed } from './session-commands.js';
import type { NewMessage } from './types.js';
import type { SessionCommandDeps } from './session-commands.js';
describe('extractSessionCommand', () => {
const trigger = /^@Andy\b/i;
it('detects bare /compact', () => {
expect(extractSessionCommand('/compact', trigger)).toBe('/compact');
});
it('detects /compact with trigger prefix', () => {
expect(extractSessionCommand('@Andy /compact', trigger)).toBe('/compact');
});
it('rejects /compact with extra text', () => {
expect(extractSessionCommand('/compact now please', trigger)).toBeNull();
});
it('rejects partial matches', () => {
expect(extractSessionCommand('/compaction', trigger)).toBeNull();
});
it('rejects regular messages', () => {
expect(extractSessionCommand('please compact the conversation', trigger)).toBeNull();
});
it('handles whitespace', () => {
expect(extractSessionCommand(' /compact ', trigger)).toBe('/compact');
});
it('is case-sensitive for the command', () => {
expect(extractSessionCommand('/Compact', trigger)).toBeNull();
});
});
describe('isSessionCommandAllowed', () => {
it('allows main group regardless of sender', () => {
expect(isSessionCommandAllowed(true, false)).toBe(true);
});
it('allows trusted/admin sender (is_from_me) in non-main group', () => {
expect(isSessionCommandAllowed(false, true)).toBe(true);
});
it('denies untrusted sender in non-main group', () => {
expect(isSessionCommandAllowed(false, false)).toBe(false);
});
it('allows trusted sender in main group', () => {
expect(isSessionCommandAllowed(true, true)).toBe(true);
});
});
function makeMsg(content: string, overrides: Partial<NewMessage> = {}): NewMessage {
return {
id: 'msg-1',
chat_jid: 'group@test',
sender: 'user@test',
sender_name: 'User',
content,
timestamp: '100',
...overrides,
};
}
function makeDeps(overrides: Partial<SessionCommandDeps> = {}): SessionCommandDeps {
return {
sendMessage: vi.fn().mockResolvedValue(undefined),
setTyping: vi.fn().mockResolvedValue(undefined),
runAgent: vi.fn().mockResolvedValue('success'),
closeStdin: vi.fn(),
advanceCursor: vi.fn(),
formatMessages: vi.fn().mockReturnValue('<formatted>'),
canSenderInteract: vi.fn().mockReturnValue(true),
...overrides,
};
}
const trigger = /^@Andy\b/i;
describe('handleSessionCommand', () => {
it('returns handled:false when no session command found', async () => {
const deps = makeDeps();
const result = await handleSessionCommand({
missedMessages: [makeMsg('hello')],
isMainGroup: true,
groupName: 'test',
triggerPattern: trigger,
timezone: 'UTC',
deps,
});
expect(result.handled).toBe(false);
});
it('handles authorized /compact in main group', async () => {
const deps = makeDeps();
const result = await handleSessionCommand({
missedMessages: [makeMsg('/compact')],
isMainGroup: true,
groupName: 'test',
triggerPattern: trigger,
timezone: 'UTC',
deps,
});
expect(result).toEqual({ handled: true, success: true });
expect(deps.runAgent).toHaveBeenCalledWith('/compact', expect.any(Function));
expect(deps.advanceCursor).toHaveBeenCalledWith('100');
});
it('sends denial to interactable sender in non-main group', async () => {
const deps = makeDeps();
const result = await handleSessionCommand({
missedMessages: [makeMsg('/compact', { is_from_me: false })],
isMainGroup: false,
groupName: 'test',
triggerPattern: trigger,
timezone: 'UTC',
deps,
});
expect(result).toEqual({ handled: true, success: true });
expect(deps.sendMessage).toHaveBeenCalledWith('Session commands require admin access.');
expect(deps.runAgent).not.toHaveBeenCalled();
expect(deps.advanceCursor).toHaveBeenCalledWith('100');
});
it('silently consumes denied command when sender cannot interact', async () => {
const deps = makeDeps({ canSenderInteract: vi.fn().mockReturnValue(false) });
const result = await handleSessionCommand({
missedMessages: [makeMsg('/compact', { is_from_me: false })],
isMainGroup: false,
groupName: 'test',
triggerPattern: trigger,
timezone: 'UTC',
deps,
});
expect(result).toEqual({ handled: true, success: true });
expect(deps.sendMessage).not.toHaveBeenCalled();
expect(deps.advanceCursor).toHaveBeenCalledWith('100');
});
it('processes pre-compact messages before /compact', async () => {
const deps = makeDeps();
const msgs = [
makeMsg('summarize this', { timestamp: '99' }),
makeMsg('/compact', { timestamp: '100' }),
];
const result = await handleSessionCommand({
missedMessages: msgs,
isMainGroup: true,
groupName: 'test',
triggerPattern: trigger,
timezone: 'UTC',
deps,
});
expect(result).toEqual({ handled: true, success: true });
expect(deps.formatMessages).toHaveBeenCalledWith([msgs[0]], 'UTC');
// Two runAgent calls: pre-compact + /compact
expect(deps.runAgent).toHaveBeenCalledTimes(2);
expect(deps.runAgent).toHaveBeenCalledWith('<formatted>', expect.any(Function));
expect(deps.runAgent).toHaveBeenCalledWith('/compact', expect.any(Function));
});
it('allows is_from_me sender in non-main group', async () => {
const deps = makeDeps();
const result = await handleSessionCommand({
missedMessages: [makeMsg('/compact', { is_from_me: true })],
isMainGroup: false,
groupName: 'test',
triggerPattern: trigger,
timezone: 'UTC',
deps,
});
expect(result).toEqual({ handled: true, success: true });
expect(deps.runAgent).toHaveBeenCalledWith('/compact', expect.any(Function));
});
it('reports failure when command-stage runAgent returns error without streamed status', async () => {
// runAgent resolves 'error' but callback never gets status: 'error'
const deps = makeDeps({ runAgent: vi.fn().mockImplementation(async (prompt, onOutput) => {
await onOutput({ status: 'success', result: null });
return 'error';
})});
const result = await handleSessionCommand({
missedMessages: [makeMsg('/compact')],
isMainGroup: true,
groupName: 'test',
triggerPattern: trigger,
timezone: 'UTC',
deps,
});
expect(result).toEqual({ handled: true, success: true });
expect(deps.sendMessage).toHaveBeenCalledWith(expect.stringContaining('failed'));
});
it('returns success:false on pre-compact failure with no output', async () => {
const deps = makeDeps({ runAgent: vi.fn().mockResolvedValue('error') });
const msgs = [
makeMsg('summarize this', { timestamp: '99' }),
makeMsg('/compact', { timestamp: '100' }),
];
const result = await handleSessionCommand({
missedMessages: msgs,
isMainGroup: true,
groupName: 'test',
triggerPattern: trigger,
timezone: 'UTC',
deps,
});
expect(result).toEqual({ handled: true, success: false });
expect(deps.sendMessage).toHaveBeenCalledWith(expect.stringContaining('Failed to process'));
});
});
+143
View File
@@ -0,0 +1,143 @@
import type { NewMessage } from './types.js';
import { logger } from './logger.js';
/**
* Extract a session slash command from a message, stripping the trigger prefix if present.
* Returns the slash command (e.g., '/compact') or null if not a session command.
*/
export function extractSessionCommand(content: string, triggerPattern: RegExp): string | null {
let text = content.trim();
text = text.replace(triggerPattern, '').trim();
if (text === '/compact') return '/compact';
return null;
}
/**
* Check if a session command sender is authorized.
* Allowed: main group (any sender), or trusted/admin sender (is_from_me) in any group.
*/
export function isSessionCommandAllowed(isMainGroup: boolean, isFromMe: boolean): boolean {
return isMainGroup || isFromMe;
}
/** Minimal agent result interface — matches the subset of ContainerOutput used here. */
export interface AgentResult {
status: 'success' | 'error';
result?: string | object | null;
}
/** Dependencies injected by the orchestrator. */
export interface SessionCommandDeps {
sendMessage: (text: string) => Promise<void>;
setTyping: (typing: boolean) => Promise<void>;
runAgent: (
prompt: string,
onOutput: (result: AgentResult) => Promise<void>,
) => Promise<'success' | 'error'>;
closeStdin: () => void;
advanceCursor: (timestamp: string) => void;
formatMessages: (msgs: NewMessage[], timezone: string) => string;
/** Whether the denied sender would normally be allowed to interact (for denial messages). */
canSenderInteract: (msg: NewMessage) => boolean;
}
function resultToText(result: string | object | null | undefined): string {
if (!result) return '';
const raw = typeof result === 'string' ? result : JSON.stringify(result);
return raw.replace(/<internal>[\s\S]*?<\/internal>/g, '').trim();
}
/**
* Handle session command interception in processGroupMessages.
* Scans messages for a session command, handles auth + execution.
* Returns { handled: true, success } if a command was found; { handled: false } otherwise.
* success=false means the caller should retry (cursor was not advanced).
*/
export async function handleSessionCommand(opts: {
missedMessages: NewMessage[];
isMainGroup: boolean;
groupName: string;
triggerPattern: RegExp;
timezone: string;
deps: SessionCommandDeps;
}): Promise<{ handled: false } | { handled: true; success: boolean }> {
const { missedMessages, isMainGroup, groupName, triggerPattern, timezone, deps } = opts;
const cmdMsg = missedMessages.find(
(m) => extractSessionCommand(m.content, triggerPattern) !== null,
);
const command = cmdMsg ? extractSessionCommand(cmdMsg.content, triggerPattern) : null;
if (!command || !cmdMsg) return { handled: false };
if (!isSessionCommandAllowed(isMainGroup, cmdMsg.is_from_me === true)) {
// DENIED: send denial if the sender would normally be allowed to interact,
// then silently consume the command by advancing the cursor past it.
// Trade-off: other messages in the same batch are also consumed (cursor is
// a high-water mark). Acceptable for this narrow edge case.
if (deps.canSenderInteract(cmdMsg)) {
await deps.sendMessage('Session commands require admin access.');
}
deps.advanceCursor(cmdMsg.timestamp);
return { handled: true, success: true };
}
// AUTHORIZED: process pre-compact messages first, then run the command
logger.info({ group: groupName, command }, 'Session command');
const cmdIndex = missedMessages.indexOf(cmdMsg);
const preCompactMsgs = missedMessages.slice(0, cmdIndex);
// Send pre-compact messages to the agent so they're in the session context.
if (preCompactMsgs.length > 0) {
const prePrompt = deps.formatMessages(preCompactMsgs, timezone);
let hadPreError = false;
let preOutputSent = false;
const preResult = await deps.runAgent(prePrompt, async (result) => {
if (result.status === 'error') hadPreError = true;
const text = resultToText(result.result);
if (text) {
await deps.sendMessage(text);
preOutputSent = true;
}
// Close stdin on session-update marker — emitted after query completes,
// so all results (including multi-result runs) are already written.
if (result.status === 'success' && result.result === null) {
deps.closeStdin();
}
});
if (preResult === 'error' || hadPreError) {
logger.warn({ group: groupName }, 'Pre-compact processing failed, aborting session command');
await deps.sendMessage(`Failed to process messages before ${command}. Try again.`);
if (preOutputSent) {
// Output was already sent — don't retry or it will duplicate.
// Advance cursor past pre-compact messages, leave command pending.
deps.advanceCursor(preCompactMsgs[preCompactMsgs.length - 1].timestamp);
return { handled: true, success: true };
}
return { handled: true, success: false };
}
}
// Forward the literal slash command as the prompt (no XML formatting)
await deps.setTyping(true);
let hadCmdError = false;
const cmdOutput = await deps.runAgent(command, async (result) => {
if (result.status === 'error') hadCmdError = true;
const text = resultToText(result.result);
if (text) await deps.sendMessage(text);
});
// Advance cursor to the command — messages AFTER it remain pending for next poll.
deps.advanceCursor(cmdMsg.timestamp);
await deps.setTyping(false);
if (cmdOutput === 'error' || hadCmdError) {
await deps.sendMessage(`${command} failed. The session is unchanged.`);
}
return { handled: true, success: true };
}