mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-05-11 21:55:04 +00:00
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:
@@ -1,9 +1,13 @@
|
||||
import {describe, test, expect, beforeEach, vi} from "vitest"
|
||||
import {mkdtempSync, writeFileSync} from "fs"
|
||||
import {tmpdir} from "os"
|
||||
import {join} from "path"
|
||||
import {core} from "simplex-chat"
|
||||
import {SupportBot} from "./src/bot.js"
|
||||
import {CardManager} from "./src/cards.js"
|
||||
import {parseConfig} from "./src/config.js"
|
||||
import {GrokApiClient} from "./src/grok.js"
|
||||
import {loadGrokContext} from "./src/context.js"
|
||||
import {welcomeMessage, queueMessage, grokActivatedMessage, teamLockedMessage, teamAlreadyInvitedMessage} from "./src/messages.js"
|
||||
|
||||
// Silence console output during tests
|
||||
@@ -2427,7 +2431,7 @@ describe("GrokApiClient HTTP timeout", () => {
|
||||
new Response(JSON.stringify({choices: [{message: {content: "ok"}}]}), {status: 200}),
|
||||
)
|
||||
|
||||
const client = new GrokApiClient("test-key", "system prompt")
|
||||
const client = new GrokApiClient("test-key", [{role: "system", content: "system prompt"}])
|
||||
await client.chat([], "hello")
|
||||
|
||||
expect(timeoutSpy).toHaveBeenCalledWith(60_000)
|
||||
@@ -2504,3 +2508,118 @@ describe("Command sync in sendToGroup", () => {
|
||||
expect(prefs.reactions).toEqual({enable: "on"})
|
||||
})
|
||||
})
|
||||
|
||||
// loadGrokContext: documented behavior is "plain text → single system
|
||||
// message". A `.yaml` / `.yml` extension is an undocumented alternative
|
||||
// that parses the harness transcript format and surfaces only `system`
|
||||
// and `assistant` turns; `user` entries are dropped so they don't merge
|
||||
// with the customer's runtime message.
|
||||
describe("loadGrokContext", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "support-bot-context-"))
|
||||
const writeFile = (name: string, content: string): string => {
|
||||
const p = join(dir, name)
|
||||
writeFileSync(p, content)
|
||||
return p
|
||||
}
|
||||
|
||||
test("plain text (.txt) → single system message with full file content", () => {
|
||||
const path = writeFile("ctx.txt", "You are Grok.\n\nBe concise.")
|
||||
expect(loadGrokContext(path)).toEqual([
|
||||
{role: "system", content: "You are Grok.\n\nBe concise."},
|
||||
])
|
||||
})
|
||||
|
||||
test("no extension → treated as plain text", () => {
|
||||
const path = writeFile("plain", "raw context")
|
||||
expect(loadGrokContext(path)).toEqual([{role: "system", content: "raw context"}])
|
||||
})
|
||||
|
||||
test(".md → treated as plain text (does not look like YAML)", () => {
|
||||
const path = writeFile("ctx.md", "# Heading\n\nbody")
|
||||
expect(loadGrokContext(path)).toEqual([
|
||||
{role: "system", content: "# Heading\n\nbody"},
|
||||
])
|
||||
})
|
||||
|
||||
test(".yaml → parses transcript and keeps only system + assistant turns", () => {
|
||||
const path = writeFile("ctx.yaml",
|
||||
"- role: system\n message: Be terse.\n" +
|
||||
"- role: user\n message: What is async?\n" +
|
||||
"- role: assistant\n message: Cooperative concurrency.\n",
|
||||
)
|
||||
expect(loadGrokContext(path)).toEqual([
|
||||
{role: "system", content: "Be terse."},
|
||||
{role: "assistant", content: "Cooperative concurrency."},
|
||||
])
|
||||
})
|
||||
|
||||
test(".yml extension also triggers YAML parsing", () => {
|
||||
const path = writeFile("ctx.yml",
|
||||
"- role: system\n message: hi\n",
|
||||
)
|
||||
expect(loadGrokContext(path)).toEqual([{role: "system", content: "hi"}])
|
||||
})
|
||||
|
||||
test("YAML parsing is case-insensitive on extension", () => {
|
||||
const path = writeFile("ctx.YAML",
|
||||
"- role: system\n message: hi\n",
|
||||
)
|
||||
expect(loadGrokContext(path)).toEqual([{role: "system", content: "hi"}])
|
||||
})
|
||||
|
||||
test("YAML preserves multi-line literal block scalars verbatim", () => {
|
||||
const path = writeFile("multiline.yaml",
|
||||
"- role: assistant\n message: |\n line one\n line two\n",
|
||||
)
|
||||
expect(loadGrokContext(path)).toEqual([
|
||||
{role: "assistant", content: "line one\nline two\n"},
|
||||
])
|
||||
})
|
||||
|
||||
test("YAML with only user-role entries → empty array", () => {
|
||||
const path = writeFile("only-user.yaml",
|
||||
"- role: user\n message: a\n" +
|
||||
"- role: user\n message: b\n",
|
||||
)
|
||||
expect(loadGrokContext(path)).toEqual([])
|
||||
})
|
||||
|
||||
test("empty YAML file → empty array", () => {
|
||||
const path = writeFile("empty.yaml", "")
|
||||
expect(loadGrokContext(path)).toEqual([])
|
||||
})
|
||||
|
||||
test("YAML non-list top level throws", () => {
|
||||
const path = writeFile("not-list.yaml", "role: system\nmessage: x\n")
|
||||
expect(() => loadGrokContext(path)).toThrow(/top-level must be a list/)
|
||||
})
|
||||
|
||||
test("YAML entry with unknown role throws", () => {
|
||||
const path = writeFile("bad-role.yaml", "- role: bogus\n message: x\n")
|
||||
expect(() => loadGrokContext(path)).toThrow(/entry 0 has invalid role/)
|
||||
})
|
||||
|
||||
test("YAML entry missing role throws", () => {
|
||||
const path = writeFile("no-role.yaml", "- message: x\n")
|
||||
expect(() => loadGrokContext(path)).toThrow(/entry 0 has invalid role/)
|
||||
})
|
||||
|
||||
test("YAML entry with non-string message throws", () => {
|
||||
const path = writeFile("bad-message.yaml", "- role: user\n message: 42\n")
|
||||
expect(() => loadGrokContext(path)).toThrow(/entry 0 has non-string message/)
|
||||
})
|
||||
|
||||
test("YAML entry that is not a mapping throws", () => {
|
||||
const path = writeFile("bad-entry.yaml", "- just a string\n- role: user\n message: x\n")
|
||||
expect(() => loadGrokContext(path)).toThrow(/entry 0 is not a mapping/)
|
||||
})
|
||||
|
||||
test("malformed YAML throws", () => {
|
||||
const path = writeFile("malformed.yaml", "- role: user\n message: [unclosed\n")
|
||||
expect(() => loadGrokContext(path)).toThrow(/failed to parse YAML/)
|
||||
})
|
||||
|
||||
test("missing file throws ENOENT", () => {
|
||||
expect(() => loadGrokContext(join(dir, "does-not-exist.yaml"))).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
+25
-9
@@ -9,10 +9,11 @@
|
||||
"version": "0.1.0",
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"@simplex-chat/types": "^0.5.0",
|
||||
"@simplex-chat/types": "^0.6.0",
|
||||
"async-mutex": "^0.5.0",
|
||||
"commander": "^14.0.3",
|
||||
"simplex-chat": "^6.5.0"
|
||||
"simplex-chat": "^6.5.1",
|
||||
"yaml": "^2.8.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
@@ -782,9 +783,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@simplex-chat/types": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@simplex-chat/types/-/types-0.5.0.tgz",
|
||||
"integrity": "sha512-f680CRlf+O8WfIaPb7wxVj3PB8mTIOE+HqmetCSe0NBheVAjU3ovg3+zkrWwDlavrHuCLbb7Gmeu4HyNtjDfog==",
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@simplex-chat/types/-/types-0.6.0.tgz",
|
||||
"integrity": "sha512-QVYvaRsS6TnS+IROjNkekZYvhvy8QoA8vKCuGe9E6lplXDVutJo9tdDOSWS9NDdtwxT1wRZ29zN4xEZEEG/NHw==",
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"typescript": "^5.9.2"
|
||||
@@ -1675,13 +1676,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/simplex-chat": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/simplex-chat/-/simplex-chat-6.5.0.tgz",
|
||||
"integrity": "sha512-QFGI734HhYJ7trSrEKiZ2mbodI0V8CLDGEv2+yt5zsg0FqftxSpFik6zUSezTRZtN1M8WmSlT44qlEt2a1fXQw==",
|
||||
"version": "6.5.1",
|
||||
"resolved": "https://registry.npmjs.org/simplex-chat/-/simplex-chat-6.5.1.tgz",
|
||||
"integrity": "sha512-1cv91iMCqtP+9R1PQM3NKazocoUInKT2/06pIOuzORD5/VzulR6cMZnEQmaT02Jz+8FVkEKTsaAIJfVP6/tJmw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"@simplex-chat/types": "^0.5.0",
|
||||
"@simplex-chat/types": "^0.6.0",
|
||||
"extract-zip": "^2.0.1",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"node-addon-api": "^8.5.0"
|
||||
@@ -1995,6 +1996,21 @@
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.4",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz",
|
||||
"integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
},
|
||||
"node_modules/yauzl": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
"@simplex-chat/types": "^0.6.0",
|
||||
"async-mutex": "^0.5.0",
|
||||
"commander": "^14.0.3",
|
||||
"simplex-chat": "^6.5.1"
|
||||
"simplex-chat": "^6.5.1",
|
||||
"yaml": "^2.8.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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},
|
||||
])
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user