support bot: accept YAML transcript in context (#6946)

* support bot: take Grok initial context as messages array

Generalizes GrokApiClient to take a list of seed messages instead of a
single system prompt. Behavior is unchanged.

* support bot: accept YAML transcript in --context-file

Plain text → single system message (existing behavior).
`.yaml`/`.yml` → parsed as harness transcript; only system and
assistant turns are included.
This commit is contained in:
sh
2026-05-11 14:37:03 +01:00
committed by GitHub
parent ce891e4501
commit 5708fbbc04
6 changed files with 225 additions and 23 deletions
+59
View File
@@ -0,0 +1,59 @@
import {readFileSync} from "fs"
import {parse as parseYaml} from "yaml"
import {GrokMessage} from "./grok.js"
const ALLOWED_ROLES: ReadonlySet<GrokMessage["role"]> = new Set(["system", "user", "assistant"])
// Roles surfaced from a YAML transcript. `user` entries from the file are
// validated but dropped — the customer's runtime message is the only
// `user` content sent to Grok.
const PREPEND_ROLES: ReadonlySet<GrokMessage["role"]> = new Set(["system", "assistant"])
// Loads --context-file. The flag is documented as "text file with Grok
// system context"; a `.yaml` / `.yml` extension is an undocumented
// alternative that switches to a multi-turn transcript in the harness
// format (a flat list of `{role, message}` entries).
export function loadGrokContext(path: string): GrokMessage[] {
const text = readFileSync(path, "utf-8")
return isYamlPath(path) ? parseYamlTranscript(path, text) : [{role: "system", content: text}]
}
function isYamlPath(path: string): boolean {
const lower = path.toLowerCase()
return lower.endsWith(".yaml") || lower.endsWith(".yml")
}
// Parses the harness transcript format. Returns only `system` and
// `assistant` turns; `user` entries are intentionally excluded so they
// don't merge with the customer's runtime message. Malformed YAML,
// unknown roles, or non-string messages throw — operator-supplied
// configuration should fail-fast at startup, not silently degrade.
function parseYamlTranscript(path: string, text: string): GrokMessage[] {
let raw: unknown
try {
raw = parseYaml(text)
} catch (e) {
throw new Error(`${path}: failed to parse YAML: ${(e as Error).message}`)
}
if (raw === null || raw === undefined) return []
if (!Array.isArray(raw)) {
throw new Error(`${path}: top-level must be a list, got ${typeof raw}`)
}
const context: GrokMessage[] = []
for (let i = 0; i < raw.length; i++) {
const entry = raw[i]
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
throw new Error(`${path}: entry ${i} is not a mapping`)
}
const {role, message} = entry as {role?: unknown; message?: unknown}
if (typeof role !== "string" || !ALLOWED_ROLES.has(role as GrokMessage["role"])) {
throw new Error(`${path}: entry ${i} has invalid role: ${JSON.stringify(role)}`)
}
if (typeof message !== "string") {
throw new Error(`${path}: entry ${i} has non-string message`)
}
if (PREPEND_ROLES.has(role as GrokMessage["role"])) {
context.push({role: role as GrokMessage["role"], content: message})
}
}
return context
}
+5 -5
View File
@@ -7,11 +7,11 @@ export interface GrokMessage {
export class GrokApiClient {
private readonly apiKey: string
private readonly systemPrompt: string
private readonly initialContext: readonly GrokMessage[]
constructor(apiKey: string, systemPrompt: string) {
constructor(apiKey: string, initialContext: readonly GrokMessage[]) {
this.apiKey = apiKey
this.systemPrompt = systemPrompt
this.initialContext = initialContext
}
async chatRaw(messages: GrokMessage[]): Promise<string> {
@@ -45,9 +45,9 @@ export class GrokApiClient {
}
async chat(history: GrokMessage[], userMessage: string): Promise<string> {
log(`Grok API call: ${history.length} history msgs, user msg ${userMessage.length} chars`)
log(`Grok API call: ${this.initialContext.length} context msgs, ${history.length} history msgs, user msg ${userMessage.length} chars`)
return this.chatRaw([
{role: "system", content: this.systemPrompt},
...this.initialContext,
...history,
{role: "user", content: userMessage},
])
+14 -7
View File
@@ -3,7 +3,8 @@ import {api, bot, util} from "simplex-chat"
import {T} from "@simplex-chat/types"
import {parseConfig} from "./config.js"
import {SupportBot} from "./bot.js"
import {GrokApiClient} from "./grok.js"
import {GrokApiClient, GrokMessage} from "./grok.js"
import {loadGrokContext} from "./context.js"
import {welcomeMessage} from "./messages.js"
import {profileMutex, log, logError, getGroupInfo, getContact} from "./util.js"
@@ -319,16 +320,22 @@ async function main(): Promise<void> {
// Load Grok context and build API client only if enabled
let grokApi: GrokApiClient | null = null
if (grokEnabled) {
let contextFile = ""
let initialContext: GrokMessage[] = []
if (config.contextFile) {
try {
contextFile = readFileSync(config.contextFile, "utf-8")
log(`Loaded Grok context: ${contextFile.length} chars from ${config.contextFile}`)
} catch {
log(`Warning: context file not found: ${config.contextFile}`)
initialContext = loadGrokContext(config.contextFile)
log(`Loaded Grok context: ${initialContext.length} message(s) from ${config.contextFile}`)
} catch (err) {
const e = err as NodeJS.ErrnoException
if (e.code === "ENOENT") {
log(`Warning: context file not found: ${config.contextFile}`)
} else {
logError(`Failed to load Grok context file ${config.contextFile}`, err)
throw err
}
}
}
grokApi = new GrokApiClient(config.grokApiKey!, contextFile)
grokApi = new GrokApiClient(config.grokApiKey!, initialContext)
}
// Create SupportBot