diff --git a/apps/simplex-support-bot/bot.test.ts b/apps/simplex-support-bot/bot.test.ts index de787ae4cc..fccd2af6b5 100644 --- a/apps/simplex-support-bot/bot.test.ts +++ b/apps/simplex-support-bot/bot.test.ts @@ -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() + }) +}) diff --git a/apps/simplex-support-bot/package-lock.json b/apps/simplex-support-bot/package-lock.json index 1569c18309..eddbcb2dff 100644 --- a/apps/simplex-support-bot/package-lock.json +++ b/apps/simplex-support-bot/package-lock.json @@ -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", diff --git a/apps/simplex-support-bot/package.json b/apps/simplex-support-bot/package.json index 0b8a3e25d1..8541056aa5 100644 --- a/apps/simplex-support-bot/package.json +++ b/apps/simplex-support-bot/package.json @@ -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", diff --git a/apps/simplex-support-bot/src/context.ts b/apps/simplex-support-bot/src/context.ts new file mode 100644 index 0000000000..81f30117e9 --- /dev/null +++ b/apps/simplex-support-bot/src/context.ts @@ -0,0 +1,59 @@ +import {readFileSync} from "fs" +import {parse as parseYaml} from "yaml" +import {GrokMessage} from "./grok.js" + +const ALLOWED_ROLES: ReadonlySet = 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 = 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 +} diff --git a/apps/simplex-support-bot/src/grok.ts b/apps/simplex-support-bot/src/grok.ts index b03108439a..37c10df25b 100644 --- a/apps/simplex-support-bot/src/grok.ts +++ b/apps/simplex-support-bot/src/grok.ts @@ -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 { @@ -45,9 +45,9 @@ export class GrokApiClient { } async chat(history: GrokMessage[], userMessage: string): Promise { - 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}, ]) diff --git a/apps/simplex-support-bot/src/index.ts b/apps/simplex-support-bot/src/index.ts index 6f392e9deb..c99b1f5842 100644 --- a/apps/simplex-support-bot/src/index.ts +++ b/apps/simplex-support-bot/src/index.ts @@ -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 { // 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