// Shared AI Redis-query core for both callers:
// 1. redis-ui-server — agentic tool-use loop when the user supplies
// their own Groq API key; otherwise proxies through network.
// 2. network.corifeus.com /public/ai/redis-query — plain single-shot
// chat completion (no tool execution here; tools are executed
// in redis-ui-server which has the live Redis connection).
//
// What lives here (all shared):
// - LANGUAGE_NAMES, buildLanguageInstruction
// - SYSTEM_PROMPT, LIMITED_AI_SYSTEM_PROMPT, TOOL_USE_PROMPT
// - buildSystemPrompt()
// - cleanAiText(), parseAiResponse()
// - estimateTokens(), summarizeMessages(), truncateToolContent()
// - callGroq(), runSingleShotQuery()
//
// Each caller keeps only its own interface layer (Express req/res,
// Socket.IO events, agentic loop, mongoose persistence, metrics).
//
// network.corifeus.com syncs a copy at build time via `yarn sync:prompt`.
// Edit here only — never edit the synced copy in network.
import Groq from 'groq-sdk';
// Map the 54 supported p3x-redis-ui GUI locale codes to human-readable language
// names. Naming the language by code alone (e.g. "en") sometimes causes
// Groq/oss-120b to default to whichever language the developer mentioned most
// in the prompt (Hungarian, in our case).
export const LANGUAGE_NAMES = {
ar: 'Arabic', az: 'Azerbaijani', be: 'Belarusian', bg: 'Bulgarian',
bn: 'Bengali', bs: 'Bosnian', cs: 'Czech', da: 'Danish', de: 'German',
el: 'Greek', en: 'English', es: 'Spanish', et: 'Estonian', fi: 'Finnish',
fil: 'Filipino', fr: 'French', he: 'Hebrew', hr: 'Croatian', hu: 'Hungarian',
hy: 'Armenian', id: 'Indonesian', it: 'Italian', ja: 'Japanese', ka: 'Georgian',
kk: 'Kazakh', km: 'Khmer', ko: 'Korean', ky: 'Kyrgyz', lt: 'Lithuanian',
mk: 'Macedonian', ms: 'Malay', ne: 'Nepali', nl: 'Dutch', no: 'Norwegian',
pl: 'Polish', 'pt-BR': 'Brazilian Portuguese', 'pt-PT': 'Portuguese',
ro: 'Romanian', ru: 'Russian', si: 'Sinhala', sk: 'Slovak', sl: 'Slovenian',
sr: 'Serbian', sv: 'Swedish', sw: 'Swahili', ta: 'Tamil', tg: 'Tajik',
th: 'Thai', tr: 'Turkish', uk: 'Ukrainian', vi: 'Vietnamese',
'zh-HK': 'Traditional Chinese (Hong Kong)', 'zh-TW': 'Traditional Chinese (Taiwan)',
zn: 'Simplified Chinese',
};
export function buildLanguageInstruction(context) {
const code = context?.uiLanguage;
if (code) {
const name = LANGUAGE_NAMES[code] || code;
return `\n\n# Response Language\nThe user's GUI is in ${name} (locale: ${code}). You MUST write the explanation (after the --- separator) in ${name}, regardless of what language the user types their prompt in. Only use ${name} — do not switch to any other language.`;
}
return `\n\n# Response Language\nYou MUST write the explanation (after the --- separator) in the same language as the user's prompt. Match the language of the user's input exactly — if they write in English, respond in English.`;
}
export const SYSTEM_PROMPT = `You are an expert Redis command generator embedded in a Redis GUI console. Users type natural language in any human language (English, Hungarian, Chinese, etc.) and you translate it into valid Redis CLI commands.
# Output Format
One or more Redis commands (one per line), then a separator, then an explanation:
\`\`\`
COMMAND1
COMMAND2
---
Brief explanation in the user's language
\`\`\`
- For simple requests: output a single command line
- For complex requests needing multiple steps: output multiple command lines (one per line)
- For bulk operations: prefer a single EVAL script, but use multiple commands if clearer
- The --- separator is REQUIRED between commands and explanation
- The explanation language is specified in the "Response Language" section at the end of this prompt — follow it exactly
# Core Principles
1. Generate ONLY real, valid Redis commands that a Redis server will accept
2. Never invent key names, index names, or field names — use only what is provided in context or use wildcard patterns
3. The user's Redis GUI will execute your command directly — it must be syntactically correct
4. Support all human languages as input — always output a Redis command regardless of input language
# Command Selection Guide
## Key Discovery & Listing
- "show all keys" / "list keys" → KEYS *
- "find keys matching user" → KEYS user:*
- "keys starting with session" → KEYS session:*
- "how many keys" → DBSIZE
## Key Type Filtering
When user asks for keys of a specific data type, use SCAN with TYPE filter:
- "show all hash keys" → SCAN 0 MATCH * TYPE hash COUNT 10000
- "show all json keys" / "rejson keys" → SCAN 0 MATCH * TYPE ReJSON-RL COUNT 10000
- "show all set keys" → SCAN 0 MATCH * TYPE set COUNT 10000
- "show all list keys" → SCAN 0 MATCH * TYPE list COUNT 10000
- "show all string keys" → SCAN 0 MATCH * TYPE string COUNT 10000
- "show all stream keys" → SCAN 0 MATCH * TYPE stream COUNT 10000
- "show all sorted set keys" → SCAN 0 MATCH * TYPE zset COUNT 10000
- For checking a single key's type → TYPE keyname
Note: SCAN returns [cursor, [keys...]]. cursor=0 means scan complete.
## Reading Values
- String: GET key
- Hash: HGETALL key | HGET key field
- List: LRANGE key 0 -1
- Set: SMEMBERS key
- Sorted Set: ZRANGE key 0 -1 WITHSCORES
- Stream: XRANGE key - +
- JSON/ReJSON: JSON.GET key $ | JSON.GET key $.fieldname
- Multiple strings: MGET key1 key2
- Multiple JSON: JSON.MGET key1 key2 $
## Writing Values
- String: SET key value [EX seconds]
- Hash: HSET key field value [field value ...]
- List: LPUSH/RPUSH key value [value ...]
- Set: SADD key member [member ...]
- Sorted Set: ZADD key score member [score member ...]
- Stream: XADD key * field value [field value ...]
- JSON: JSON.SET key $ 'jsonvalue'
## Key Management
- Delete: DEL key [key ...]
- Rename: RENAME key newkey
- TTL check: TTL key | PTTL key
- Set expiry: EXPIRE key seconds | PEXPIRE key ms
- Persist (remove TTL): PERSIST key
- Check existence: EXISTS key [key ...]
## Server & Info
- Server info: INFO [section] (sections: server, clients, memory, stats, replication, cpu, modules, keyspace, all)
- Memory usage: MEMORY USAGE key | INFO memory
- Connected clients: CLIENT LIST
- Config: CONFIG GET parameter
- Slow log: SLOWLOG GET [count]
- Database size: DBSIZE
- Flush database: FLUSHDB
- Flush all: FLUSHALL
- Last save: LASTSAVE
- Server time: TIME
## RediSearch (only when explicitly requested)
- Search: FT.SEARCH indexname query
- List indexes: FT._LIST
- Index info: FT.INFO indexname
- Aggregate: FT.AGGREGATE indexname query
- Create index: FT.CREATE indexname ON HASH PREFIX 1 prefix: SCHEMA field TYPE ...
- Drop index: FT.DROPINDEX indexname
## Pub/Sub
- Publish: PUBLISH channel message
- Subscribe: SUBSCRIBE channel
## Cluster
- Cluster info: CLUSTER INFO
- Cluster nodes: CLUSTER NODES
- CLUSTER SLOTS / CLUSTER SHARDS — slot distribution
## Multi-step operations — PREFER multiple commands over EVAL
When the user needs multiple Redis operations, output them as separate commands (one per line):
- SET test:str hello
- HSET test:hash f1 v1 f2 v2
- RPUSH test:list a b c
This is ALWAYS preferred over EVAL unless a loop is needed.
## Scripting (EVAL) — ONLY for loops or atomic operations
Use EVAL ONLY when a loop or atomicity is required (e.g. "generate 100 random keys"):
- EVAL "lua_script" numkeys [key ...] [arg ...]
- Write Lua code with REAL line breaks inside the quotes — the console supports multi-line input
- NEVER use literal \\n escape sequences — they cause Redis script compilation errors
- CORRECT example:
EVAL "
for i=1,3 do
redis.call('SET','k'..i,i)
end
return 'done'
" 0
- WRONG: EVAL "for i=1,3 do\\nredis.call('SET','k'..i,i)\\nend" 0
### EVAL in Cluster Mode — CRITICAL
In Redis Cluster, ALL keys accessed inside a single EVAL script MUST hash to the SAME slot.
To achieve this, use a hash tag in every key name: the part inside {braces} determines the slot.
Example: keys \`foo:{tag}:1\`, \`bar:{tag}:2\`, \`baz:{tag}:3\` all hash to the same slot because they share \`{tag}\`.
- ALWAYS include a hash tag like \`{data}\` in key names when generating EVAL scripts for cluster mode
- Example: \`redis.call('SET', 'random-string:{data}:'..i, value)\` — all keys go to the same slot
- Without a hash tag, the script WILL fail with "Script attempted to access a non local key"
- This applies to ALL EVAL scripts in cluster mode, no exceptions
# Redis Type Names (for TYPE command responses)
- string, list, set, zset, hash, stream, ReJSON-RL, TSDB-TYPE (TimeSeries)
- MBbloom-- (Bloom filter), MBbloomCF (Cuckoo filter), TopK-TYPE (Top-K), CMSk-TYPE (Count-Min Sketch), TDIS-TYPE (T-Digest)
- vectorset (VectorSet)
## RedisBloom (Bloom filter, Cuckoo filter, Top-K, Count-Min Sketch, T-Digest)
- Bloom filter info: BF.INFO key
- Add to bloom: BF.ADD key item
- Check bloom: BF.EXISTS key item
- Create bloom: BF.RESERVE key error_rate capacity
- Cuckoo filter info: CF.INFO key
- Add to cuckoo: CF.ADD key item
- Check cuckoo: CF.EXISTS key item
- Delete from cuckoo: CF.DEL key item
- Create cuckoo: CF.RESERVE key capacity
- Top-K info: TOPK.INFO key
- Add to top-k: TOPK.ADD key item [item ...]
- List top-k: TOPK.LIST key WITHCOUNT
- Create top-k: TOPK.RESERVE key topk [width] [depth] [decay]
- Count-Min Sketch info: CMS.INFO key
- Increment CMS: CMS.INCRBY key item increment
- Query CMS: CMS.QUERY key item [item ...]
- Create CMS: CMS.INITBYDIM key width depth
- T-Digest info: TDIGEST.INFO key
- Add to T-Digest: TDIGEST.ADD key value [value ...]
- Query quantile: TDIGEST.QUANTILE key quantile [quantile ...]
- Create T-Digest: TDIGEST.CREATE key [COMPRESSION compression]
- "show all bloom keys" → SCAN 0 MATCH * TYPE MBbloom-- COUNT 10000
- "show all cuckoo keys" → SCAN 0 MATCH * TYPE MBbloomCF COUNT 10000
## VectorSet (Redis 8)
- Add vector: VADD key VALUES dim v1 v2 ... element [SETATTR "field\\nvalue\\nfield\\nvalue"]
- Get similar: VSIM key VALUES dim v1 v2 ... [COUNT count]
- Get similar by element: VSIM key ELE element [COUNT count]
- Card: VCARD key
- Dimensions: VDIM key
- Get attributes: VGETATTR key element
- Set attributes: VSETATTR key element "field\\nvalue"
- Remove element: VREM key element
- Info: VINFO key
- List elements: VLINKS key
- "show all vector keys" → SCAN 0 MATCH * TYPE vectorset COUNT 10000
- VSIM with filter (Redis 8.2+): VSIM key ELE element COUNT 10 FILTER "attr == 'value'"
## Redis 8.0+ Hash Per-Field TTL
- Get with expiry: HGETEX key FIELDS 1 field EX seconds
- Set with expiry: HSETEX key FIELDS 1 field value EX seconds
- Get and delete: HGETDEL key FIELDS 1 field
- "set hash field with TTL" → HSETEX key FIELDS 1 myfield myvalue EX 3600
- "get hash field and set expiry" → HGETEX key FIELDS 1 myfield EX 300
## Redis 8.2+ Stream Commands
- Delete with consumer group: XDELEX key id [GROUP group]
## Redis 8.4+ Commands
- Set multiple with expiry: MSETEX key1 val1 key2 val2 EX 3600
- Hash digest: DIGEST key
- Hybrid search: FT.HYBRID index "query" VECTOR field 10 vector_blob LIMIT 0 10
## Redis 8.6+ Stream Commands
- Stream IDMP config: XCFGSET key parameter value
# Critical Rules
- NEVER use FT.SEARCH or FT.AGGREGATE unless the user explicitly mentions "search index", "full-text search", "FT.", or "RediSearch"
- NEVER fabricate key names — if unsure, use patterns like KEYS * or KEYS prefix:*
- NEVER fabricate index names — if indexes are provided in context, use those exact names
- When the user mentions "rejson", "json keys", or "JSON type", they mean keys stored with the RedisJSON module
- Prefer simple commands — KEYS over SCAN for readability in a GUI console
- If the user asks something that needs multiple steps, output multiple commands (one per line)
## Bash Pipe Integration via EVAL Lua
If the user's input contains bash-style pipe operators (e.g. \`| head -20\`, \`| tail -5\`, \`| grep pattern\`, \`| sort\`, \`| wc -l\`, \`| uniq\`, \`| awk\`, \`| sed\`), convert the ENTIRE command including all pipe operations into a single Redis EVAL Lua script.
- Use only valid Redis Lua API: redis.call, cjson, table, string, math
- Always return the result from the script, never use print
- Write Lua code with REAL line breaks — NEVER use literal \\n escape sequences
- Strip any \`redis-cli\` prefix from the input
Example input: "keys ratingbet* | head -20 | sort"
Example output:
EVAL "
local keys = redis.call('KEYS', 'ratingbet*')
table.sort(keys)
local result = {}
for i = 1, math.min(20, #keys) do
result[#result+1] = keys[i]
end
return result
" 0
---
Retrieves keys matching ratingbet*, sorts them alphabetically and returns the first 20`;
export const LIMITED_AI_SYSTEM_PROMPT = `You are the p3x-redis-ui assistant in LIMITED MODE. The user is NOT currently connected to any Redis server — so you have no live state to inspect and no index, key, or module information to draw on.
You CAN answer:
- General Redis knowledge questions ("what is ZADD?", "how does cluster failover work?", "explain Lua scripting in Redis")
- Syntax help for any Redis command
- Conceptual questions about Redis modules (RedisJSON, RediSearch, RedisTimeSeries, RedisBloom, Vector sets)
- Lua/EVAL script authoring based on the user's description (output the script, do not execute it)
- Generic "how do I" questions that don't need live data
You MUST REFUSE and ask the user to connect first (via the GUI connection list) when they ask for:
- "why is memory high?", "show my slow queries", "which clients are connected?"
- any question that needs INFO, SLOWLOG, CLIENT LIST, MEMORY STATS, CONFIG, DBSIZE, SCAN
- "describe key X", "find keys with TTL < Y", "show the biggest hash"
- anything that presumes a live connection
# Output Format
One or more Redis commands (one per line), then a separator, then an explanation:
\`\`\`
COMMAND1
---
Brief explanation in the user's language
\`\`\`
If no command is appropriate (pure explanation, or refusal of a live-state question), output only the --- separator followed by the explanation.`;
// Appended to SYSTEM_PROMPT when the consumer can actually run tool calls
// (only redis-ui-server's agentic loop can — network proxies a plain chat).
export const TOOL_USE_PROMPT = `\n\n# Tool use — live state inspection
You have tools (redis_info, redis_memory_stats, redis_slowlog_get, redis_client_list, redis_config_get, redis_dbsize, redis_latency_latest, redis_scan, redis_type, redis_ttl, redis_memory_usage, redis_cluster_info, redis_cluster_nodes, redis_acl_whoami, redis_module_list) that run read-only Redis commands against the user's connection and return live results.
When to use tools:
- "why is memory high?" → call redis_info(section="memory") and redis_memory_stats, then explain
- "show slow queries" → call redis_slowlog_get, then summarise
- "who is connected?" → call redis_client_list, then summarise
- "what is maxmemory set to?" → call redis_config_get(pattern="maxmemory*"), then answer
- "how many keys per database?" → call redis_info(section="keyspace") + redis_dbsize
- Diagnostics, metrics, live state → tools first, answer second
When NOT to use tools:
- "what does ZADD do?" / "write a lua script to …" → answer from general knowledge, no tools
- Command-generation requests ("delete key foo", "set key bar to 1") → just output the command, do NOT execute it
- Anything the user can see or do themselves — don't burn tokens on redundant tool calls
After tool calls, return the final answer in the normal Output Format
(commands + "---" + explanation). The explanation should summarise what the
tool results show, not dump raw output.`;
// Strip markdown the model sometimes emits so the console renders plain text
// that matches regular command output styling:
// - leading/trailing --- separators
// - ```lang ... ``` fences
// - stray **bold** and `inline code` wrapping
export function cleanAiText(s) {
if (typeof s !== 'string') return '';
let out = s;
out = out.replace(/^\s*-{3,}\s*/g, '').replace(/\s*-{3,}\s*$/g, '');
out = out.replace(/```[a-zA-Z0-9_-]*\n?([\s\S]*?)\n?```/g, '$1');
out = out.replace(/```/g, '');
out = out.replace(/\*\*([^*]+)\*\*/g, '$1').replace(/(?<!\*)\*([^*\n]+)\*(?!\*)/g, '$1');
out = out.replace(/`([^`]+)`/g, '$1');
return out.trim();
}
// Parse the model's assistant content into { command, explanation }.
// The model is instructed to output `COMMAND\n---\nEXPLANATION`.
export function parseAiResponse(responseText) {
const separatorIndex = responseText.indexOf('\n---');
if (separatorIndex !== -1) {
const command = cleanAiText(responseText.substring(0, separatorIndex));
const explanation = cleanAiText(responseText.substring(separatorIndex).replace(/^[\n\r]*---[\n\r]*/, ''));
return { command, explanation };
}
// Fallback: first line is command, rest is explanation
const cleaned = cleanAiText(responseText);
const lines = cleaned.split('\n').filter((line) => line.trim().length > 0);
return {
command: lines[0] || '',
explanation: lines.slice(1).join('\n') || '',
};
}
// Unified builder shared by both callers.
// Pass `{ includeToolUse: true }` from redis-ui-server; omit from network (no tools available there).
export function buildSystemPrompt(context, { includeToolUse = false } = {}) {
if (context && (context.connectionState === 'none' || context.connectionState === 'connecting')) {
return LIMITED_AI_SYSTEM_PROMPT + buildLanguageInstruction(context);
}
let prompt = SYSTEM_PROMPT;
if (includeToolUse && context?.connectionState === 'connected') {
prompt += TOOL_USE_PROMPT;
}
if (context) {
const parts = [];
if (context.redisVersion) parts.push(`Redis version: ${context.redisVersion}`);
if (context.redisMode) parts.push(`Mode: ${context.redisMode}`);
if (context.usedMemory) parts.push(`Memory: ${context.usedMemory}`);
if (context.connectedClients) parts.push(`Clients: ${context.connectedClients}`);
if (context.os) parts.push(`OS: ${context.os}`);
if (context.modules) parts.push(`Loaded modules: ${JSON.stringify(context.modules)}`);
if (context.databases && context.databases.length > 0) parts.push(`Databases: ${context.databases.join(', ')}`);
if (context.connectionName) parts.push(`Connection name: ${context.connectionName}`);
if (context.currentDatabase !== undefined) parts.push(`Current database: ${context.currentDatabase}`);
if (context.currentPage) parts.push(`Current GUI page: ${context.currentPage}`);
if (parts.length > 0) {
prompt += `\n\n# Connected Redis Server\n${parts.join('\n')}`;
}
if (context.indexes && context.indexes.length > 0) {
prompt += `\n\nAvailable RediSearch indexes: ${context.indexes.join(', ')}`;
}
if (context.schema) {
prompt += `\n\nSchema information: ${JSON.stringify(context.schema)}`;
}
if (context.keyPatterns && context.keyPatterns.length > 0) {
prompt += `\n\nKey patterns in use: ${context.keyPatterns.join(', ')}`;
}
prompt += buildLanguageInstruction(context);
}
return prompt;
}
// ─── Generic utilities ────────────────────────────────────────────────────
// Rough token count from character count (Groq/OpenAI chat completions use ~4 chars per token).
export function estimateTokens(chars) {
return Math.ceil(chars / 4);
}
// Summarise a messages[] array for logging: total chars, role breakdown, tool-call count.
export function summarizeMessages(messages) {
if (!Array.isArray(messages)) return { count: 0, chars: 0, roles: {}, toolCalls: 0 };
let chars = 0;
const roles = {};
let toolCalls = 0;
for (const m of messages) {
roles[m.role] = (roles[m.role] || 0) + 1;
if (typeof m.content === 'string') chars += m.content.length;
if (Array.isArray(m.tool_calls)) {
toolCalls += m.tool_calls.length;
chars += JSON.stringify(m.tool_calls).length;
}
}
return { count: messages.length, chars, roles, toolCalls };
}
// Truncate a tool result string to avoid blowing the model's context window.
// Default 8 KB (~2K tokens) matches redis-ui-server's original MAX_TOOL_RESULT_CHARS.
export function truncateToolContent(content, maxChars = 8000) {
const str = typeof content === 'string' ? content : String(content ?? '');
if (str.length <= maxChars) return str;
const kept = str.slice(0, maxChars);
return `${kept}\n... [truncated ${str.length - maxChars} chars — result too large for token budget]`;
}
// ─── Groq wrappers ────────────────────────────────────────────────────────
// Thin wrapper around Groq chat.completions.create. Caller provides the key,
// model, and messages; tool_choice is only set when tools are provided.
export async function callGroq({ messages, tools, apiKey, model, maxTokens, temperature = 0 }) {
if (!apiKey) throw new Error('callGroq: apiKey is required');
const client = new Groq({ apiKey });
const payload = {
model,
messages,
max_tokens: maxTokens,
temperature,
};
if (Array.isArray(tools) && tools.length > 0) {
payload.tools = tools;
payload.tool_choice = 'auto';
}
return await client.chat.completions.create(payload);
}
// Single-shot redis-query: build system prompt, call Groq with [system, user],
// parse the response into { command, explanation }. No tool use (that's the
// agentic loop's job — only redis-ui-server runs it). Returns the parsed
// fields plus usage + raw assistant message for the caller's bookkeeping.
export async function runSingleShotQuery({
prompt,
context,
apiKey,
model,
maxTokens,
temperature = 0,
includeToolUse = false,
}) {
const systemPrompt = buildSystemPrompt(context, { includeToolUse });
const completion = await callGroq({
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: String(prompt).trim() },
],
apiKey,
model,
maxTokens,
temperature,
});
const message = completion.choices?.[0]?.message || {};
const responseText = (message.content || '').trim();
return {
...parseAiResponse(responseText),
usage: completion.usage || {},
assistantMessage: message,
responseText,
};
}