diff --git a/CONTEXT.md b/CONTEXT.md index 2633134..0aa6f23 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -87,3 +87,7 @@ _Avoid_: Allow-listed app, multi-line app, safe app **Break placement**: The choice of where paragraph breaks belong in one dictation. The rewrite model server decides it, and answers with sentence numbers rather than with text. _Avoid_: Paragraph inference, auto-formatting, LLM cleanup + +**Post-processing hook** (#259): +An optional, user-supplied script the finished text is piped through (stdin in, stdout out) as the very last step before delivery - after rules cleanup and break placement, not instead of them. Unlike those, it is off by default, arbitrary (the script can do anything, including call out to a local model), and never blocks delivery: a failure, a timeout, or a missing script falls back to the text as it stood before the hook ran. Distinct from Voice edit, which transforms text the user already selected on request, not a dictation in flight. +_Avoid_: Post-processing plugin, middleware, filter diff --git a/docs/adr/0004-shell-script-post-processing-hook.md b/docs/adr/0004-shell-script-post-processing-hook.md new file mode 100644 index 0000000..4a32d7d --- /dev/null +++ b/docs/adr/0004-shell-script-post-processing-hook.md @@ -0,0 +1,54 @@ +--- +status: accepted +--- + +# A shell script, not an LLM prompt, for the post-processing hook + +[#259](https://github.com/Nabzx/openstream/issues/259) asked for a hook between +Rules-cleanup and delivery, and named two candidate shapes: a user-supplied shell +script piped the finished text, or a user-supplied prompt sent to the existing +rewrite model server. Building both was on the table too. + +**Shell script only, configured as a path to an existing executable file, not an +inline command typed into Settings.** A script subsumes the LLM case rather than +sitting beside it - anyone who wants an LLM in their post-processing step can write a +script that calls one, local or otherwise, and OpenStream needs no second +process-execution surface, no prompt-template UI, and no dependency on the rewrite +model server's quality (already found unreliable at following instructions - +[#222](https://github.com/Nabzx/openstream/issues/222)'s spike). A script path also +keeps Settings from becoming a place where a full shell command is typed and run +directly from a text box; the user writes and maintains the script in their own +editor, and OpenStream only ever points at it. + +This is a deliberate acceptance of an arbitrary-code-execution feature, not an +oversight. The script runs with the same access the OpenStream process has, on text +the user just spoke - there is no sandboxing beyond a timeout, and none was built. The +mitigations are: **off by default** (`postProcessScriptPath: null`), a file picker +rather than a pasted command (the user already had to have the script on disk), and an +explicit warning in Settings that the script runs with full access and should only +point at something the user trusts. Anyone using this feature is knowingly running +their own code on their own machine, the same trust boundary as any other script they +write and run themselves - this is not a plugin marketplace or a shared-script +feature, and should not become one without revisiting this decision. + +## Consequences + +- **`electron/postProcessHook.js` owns the mechanism** (spawn, pipe stdin, collect + stdout, enforce a timeout, kill on expiry); `dictationCoordinator.js` owns the + policy (a failure or timeout falls back to the un-hooked text, never blocks + delivery, logged not surfaced). The split mirrors every other adapter in the + coordinator - the pure decision logic is what's unit-tested against a fake, the + real child-process mechanics are what's tested against real scripts. +- **No LLM-prompt hook exists.** If model-driven post-processing is wanted later + (semantic voice edits per #259's own framing, or a "make this shorter" prompt). + it can either be its own script (calling out to `llama-server`'s existing + `/v1/chat/completions` endpoint, or any other local model) or a genuine second + hook type - revisit this ADR if a real need for the latter shows up. +- **No inline shell-command field, and no plan to add one.** If that convenience is + wanted later, it changes this ADR's trust framing (a copy-pasted one-liner is a + weaker signal of "the user wrote and understands this" than a file they authored) + and should be a deliberate reopening, not a quiet UI addition. +- **The timeout (10s default, `postProcessHook.js`'s `DEFAULT_TIMEOUT_MS`) is not + exposed in Settings.** A user who wants a slower hook (one that calls out to a + local LLM, say) is out of luck until this is revisited - kept minimal for v1 + rather than guessed at. diff --git a/electron/dictationCoordinator.js b/electron/dictationCoordinator.js index 3d614b7..3a5cef4 100644 --- a/electron/dictationCoordinator.js +++ b/electron/dictationCoordinator.js @@ -66,6 +66,10 @@ function createDictationIntake(options) { // "paste" command. Callers/tests that don't wire it get no paste // command, everything else unchanged. clipboard = null, + // #259: an optional user-supplied post-processing hook, the last + // transform before delivery. Defaults to a no-op passthrough so + // callers/tests that don't wire it see today's behaviour. + postProcess = { run: async (text) => text }, } = options; assertAdapter("transcription", transcription, "transcribe"); @@ -75,6 +79,7 @@ function createDictationIntake(options) { assertAdapter("vocabulary", vocabulary, "getPrompt"); assertAdapter("corrections", corrections, "getEntries"); assertAdapter("language", language, "get"); + assertAdapter("postProcess", postProcess, "run"); let queue = Promise.resolve(); @@ -308,6 +313,28 @@ function createDictationIntake(options) { } } + // #259: the last transform before delivery, and optional - most callers + // have no hook configured and postProcess.run() is a no-op passthrough. + // A failure or timeout never blocks delivery: it falls back to the text + // as it stood before the hook ran, logged but not surfaced to the user. + try { + const hooked = await postProcess.run(finishedText); + if (typeof hooked !== "string") { + throw new Error("post-process hook adapter returned a non-string result"); + } + emitDiagnostic("postProcess.applied", hooked !== finishedText); + finishedText = hooked; + } catch (error) { + emitDiagnostic("postProcess.failure", errorMessage(error)); + } + + // A hook can deliberately return empty text (a redaction filter that + // decides there's nothing left to say) - treat that the same as the + // no-speech check earlier, rather than delivering nothing. + if (!finishedText) { + return { status: "no-speech" }; + } + try { // #355: pass the bundle just confirmed as frontmost, so the helper's // own abort check (which watches for the app changing again, this diff --git a/electron/dictationCoordinator.test.js b/electron/dictationCoordinator.test.js index 07a49e9..982f85f 100644 --- a/electron/dictationCoordinator.test.js +++ b/electron/dictationCoordinator.test.js @@ -26,6 +26,9 @@ function createIntake({ listDetection, // #375: undefined = no clipboard adapter wired at all. clipboardText, + // #259: undefined = no post-processing hook adapter wired (the default + // no-op passthrough). + postProcess, } = {}) { const diagnostics = []; const delivered = []; @@ -56,6 +59,7 @@ function createIntake({ onDiagnostic: onDiagnostic || ((name, value) => diagnostics.push([name, value])), listDetection, clipboard: clipboardText !== undefined ? { readText: () => clipboardText } : null, + ...(postProcess !== undefined ? { postProcess } : {}), }); return { intake, diagnostics, delivered, breakCalls }; @@ -397,6 +401,7 @@ test("repairs malformed break indices without retrying and records format and re ["paragraphBreaks.repairUsed", true], ["listBoundaries.formatValid", true], ["listBoundaries.repairUsed", false], + ["postProcess.applied", false], ]); }); @@ -457,6 +462,7 @@ test("a flagged spoken list renders as bullets set off from the surrounding pros ["paragraphBreaks.repairUsed", false], ["listBoundaries.formatValid", true], ["listBoundaries.repairUsed", false], + ["postProcess.applied", false], ]); }); @@ -486,6 +492,7 @@ test("an out-of-range list range is clamped into the text and recorded as repair ["paragraphBreaks.repairUsed", false], ["listBoundaries.formatValid", true], ["listBoundaries.repairUsed", true], + ["postProcess.applied", false], ]); }); @@ -515,6 +522,7 @@ test("a malformed LIST line fails closed to prose without dropping paragraph bre ["paragraphBreaks.repairUsed", false], ["listBoundaries.formatValid", false], ["listBoundaries.repairUsed", false], + ["postProcess.applied", false], ]); }); @@ -543,6 +551,7 @@ test("list detection is off by default: a valid range is parsed and reported but ["paragraphBreaks.repairUsed", false], ["listBoundaries.formatValid", true], ["listBoundaries.repairUsed", false], + ["postProcess.applied", false], ]); }); @@ -805,6 +814,88 @@ test("held delivery preserves the complete finished text without retrying delive assert.deepEqual(delivered, ["Hello world."]); }); +test("#259: with no post-process hook configured, text is delivered unchanged", async () => { + const harness = createIntake({ transcript: "hello world" }); + + const result = await harness.intake.complete(completedWav); + + assert.deepEqual(result, { status: "delivered", text: "Hello world." }); + assert.deepEqual(harness.delivered, ["Hello world."]); +}); + +test("#259: a post-process hook's output is what gets delivered", async () => { + const harness = createIntake({ + transcript: "hello world", + postProcess: { run: async (text) => text.toUpperCase() }, + }); + + const result = await harness.intake.complete(completedWav); + + assert.deepEqual(result, { status: "delivered", text: "HELLO WORLD." }); + assert.deepEqual(harness.delivered, ["HELLO WORLD."]); + assert.ok(harness.diagnostics.some(([name, value]) => name === "postProcess.applied" && value === true)); +}); + +test("#259: a hook that throws falls back to the un-hooked text, delivery still proceeds", async () => { + const harness = createIntake({ + transcript: "hello world", + postProcess: { + run: async () => { + throw new Error("script exited with code 1"); + }, + }, + }); + + const result = await harness.intake.complete(completedWav); + + assert.deepEqual(result, { status: "delivered", text: "Hello world." }); + assert.deepEqual(harness.delivered, ["Hello world."]); + assert.ok( + harness.diagnostics.some(([name, value]) => name === "postProcess.failure" && value === "script exited with code 1"), + ); +}); + +test("#259: a hook returning a non-string is treated as a failure, falls back", async () => { + const harness = createIntake({ + transcript: "hello world", + postProcess: { run: async () => 42 }, + }); + + const result = await harness.intake.complete(completedWav); + + assert.deepEqual(result, { status: "delivered", text: "Hello world." }); + assert.ok(harness.diagnostics.some(([name]) => name === "postProcess.failure")); +}); + +test("#259: a hook that deliberately empties the text results in no-speech, not an empty delivery", async () => { + const harness = createIntake({ + transcript: "hello world", + postProcess: { run: async () => "" }, + }); + + const result = await harness.intake.complete(completedWav); + + assert.deepEqual(result, { status: "no-speech" }); + assert.deepEqual(harness.delivered, []); +}); + +test("#259: a held delivery carries the hook's output, not the pre-hook text", async () => { + const delivered = []; + const harness = createIntake({ + transcript: "hello world", + postProcess: { run: async (text) => text.toUpperCase() }, + deliver: async (text) => { + delivered.push(text); + return { kind: "held", reason: "unverified target" }; + }, + }); + + const result = await harness.intake.complete(completedWav); + + assert.deepEqual(result, { status: "held", text: "HELLO WORLD.", reason: "unverified target" }); + assert.deepEqual(delivered, ["HELLO WORLD."]); +}); + test("completed recordings are processed and delivered in FIFO order", async () => { const firstTranscription = {}; const firstTranscriptionReady = new Promise((resolve) => { diff --git a/electron/main.js b/electron/main.js index f19a45b..840a4f4 100644 --- a/electron/main.js +++ b/electron/main.js @@ -26,6 +26,7 @@ const hotkeyHelper = require("./hotkeyHelper"); const accessibilityHelper = require("./accessibilityHelper"); const { createSettingsStore } = require("./settingsStore"); const { createRecordingHistoryStore } = require("./recordingHistoryStore"); +const { createPostProcessHook } = require("./postProcessHook"); const { createIdleUnloader } = require("./idleUnloader"); const { createMediaPause } = require("./mediaPause"); const { createSoundCues } = require("./soundCues"); @@ -205,6 +206,13 @@ function soundCuesEnabled() { return Boolean(settingsStore && settingsStore.get().soundCues); } +// #259: reads the configured script path fresh on every dictation, same +// reasoning as corrections/language below - a change in Settings applies +// to the very next utterance, no restart needed. +const postProcessHook = createPostProcessHook({ + getScriptPath: () => (settingsStore ? settingsStore.get().postProcessScriptPath : null), +}); + const dictationIntake = createDictationIntake({ transcription, // #375: a spoken "paste" reads from here. @@ -218,6 +226,7 @@ const dictationIntake = createDictationIntake({ corrections: { getEntries: () => (settingsStore ? settingsStore.get().termCorrections : []) }, // #252: the transcription input language, same fresh read. language: { get: () => (settingsStore ? settingsStore.get().inputLanguage : "en") }, + postProcess: postProcessHook, onDiagnostic: recordDictationDiagnostic, }); @@ -1111,6 +1120,22 @@ ipcMain.handle("settings:set-history-max-entries", (event, count) => { return settingsStore.setHistoryMaxEntries(count); }); +// #259: clears the hook when passed null; picking a new script goes through +// settings:pick-post-process-script instead, which sets it in one round trip. +ipcMain.handle("settings:set-post-process-script", (event, scriptPath) => { + return settingsStore.setPostProcessScriptPath(scriptPath); +}); + +ipcMain.handle("settings:pick-post-process-script", async () => { + if (!win) return null; + const result = await dialog.showOpenDialog(win, { + properties: ["openFile"], + message: "Choose a script to run the finished text through before it's delivered", + }); + if (result.canceled || result.filePaths.length === 0) return null; + return settingsStore.setPostProcessScriptPath(result.filePaths[0]); +}); + // #19: pick an app from disk instead of hunting down its bundle id by // hand. Returns { bundleId, name } for the renderer to add, or null if the // dialog was cancelled; a bundle with no readable identifier rejects. diff --git a/electron/postProcessHook.js b/electron/postProcessHook.js new file mode 100644 index 0000000..100ef98 --- /dev/null +++ b/electron/postProcessHook.js @@ -0,0 +1,87 @@ +const { spawn } = require("child_process"); + +// #259: an optional, user-supplied script the cleaned, finished text is +// piped through (stdin) right before delivery - a shell hook rather than a +// second LLM path, since a script can call out to anything (a local LLM +// CLI included) and needs no new process-execution surface of its own. +// +// This module only knows how to run one script and enforce a timeout - it +// never decides what a failure means for the dictation. That policy +// ("fall back to the un-hooked text, never block delivery") lives in +// dictationCoordinator.js, which is what actually gets unit-tested against +// a fake; this file is what gets tested against real child processes. +const DEFAULT_TIMEOUT_MS = 10_000; + +function createPostProcessHook({ getScriptPath, timeoutMs = DEFAULT_TIMEOUT_MS, spawnFn = spawn }) { + async function run(text) { + const scriptPath = getScriptPath(); + if (!scriptPath) return text; + return runScript(spawnFn, scriptPath, text, timeoutMs); + } + + return { run }; +} + +function runScript(spawnFn, scriptPath, text, timeoutMs) { + return new Promise((resolve, reject) => { + let child; + try { + child = spawnFn(scriptPath, [], { stdio: ["pipe", "pipe", "pipe"] }); + } catch (error) { + reject(error); + return; + } + + let stdout = ""; + let stderr = ""; + let settled = false; + + const timer = setTimeout(() => { + if (settled) return; + settled = true; + child.kill("SIGKILL"); + reject(new Error(`post-process hook timed out after ${timeoutMs}ms`)); + }, timeoutMs); + + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + + // A script that isn't executable, or doesn't exist, fires this rather + // than "close" - covers the "the script got moved or deleted" case. + child.on("error", (error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + reject(error); + }); + + child.on("close", (code) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (code !== 0) { + const detail = stderr.trim(); + reject(new Error(`post-process hook exited with code ${code}${detail ? `: ${detail}` : ""}`)); + return; + } + // A CLI convention, not part of the dictation - a script that prints + // with `echo`/`puts`/`print` adds exactly one trailing newline that + // was never something the user said. + resolve(stdout.replace(/\n$/, "")); + }); + + child.stdin.on("error", () => { + // A script that closes stdin early (reads nothing, e.g. `cat /dev/null`) + // would otherwise throw EPIPE on this write - "close" above still + // fires and decides the outcome from the exit code. + }); + child.stdin.write(text); + child.stdin.end(); + }); +} + +module.exports = { createPostProcessHook, DEFAULT_TIMEOUT_MS }; diff --git a/electron/postProcessHook.test.js b/electron/postProcessHook.test.js new file mode 100644 index 0000000..56fb81c --- /dev/null +++ b/electron/postProcessHook.test.js @@ -0,0 +1,69 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const { createPostProcessHook, DEFAULT_TIMEOUT_MS } = require("./postProcessHook"); + +// Real executable scripts, not fakes - the point of this file is to prove +// the actual spawn/stdin/stdout/timeout mechanics work, since +// dictationCoordinator.test.js already covers the fallback policy against a +// fake adapter. +function writeScript(body) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openstream-hook-")); + const scriptPath = path.join(dir, "hook.sh"); + fs.writeFileSync(scriptPath, `#!/bin/sh\n${body}\n`, { mode: 0o755 }); + return scriptPath; +} + +test("no script configured: the text passes through unchanged", async () => { + const hook = createPostProcessHook({ getScriptPath: () => null }); + const result = await hook.run("hello world"); + assert.equal(result, "hello world"); +}); + +test("runs the script, pipes text in on stdin, returns stdout", async () => { + const scriptPath = writeScript("tr a-z A-Z"); + const hook = createPostProcessHook({ getScriptPath: () => scriptPath }); + const result = await hook.run("hello world"); + assert.equal(result, "HELLO WORLD"); +}); + +test("strips exactly one trailing newline, keeps internal ones", async () => { + const scriptPath = writeScript('printf "line one\\nline two\\n"'); + const hook = createPostProcessHook({ getScriptPath: () => scriptPath }); + const result = await hook.run("ignored"); + assert.equal(result, "line one\nline two"); +}); + +test("a non-zero exit rejects with the exit code and stderr", async () => { + const scriptPath = writeScript('echo "went wrong" 1>&2; exit 3'); + const hook = createPostProcessHook({ getScriptPath: () => scriptPath }); + await assert.rejects(() => hook.run("text"), /exited with code 3.*went wrong/s); +}); + +test("a missing script rejects rather than hanging", async () => { + const hook = createPostProcessHook({ getScriptPath: () => "/no/such/script-really-does-not-exist.sh" }); + await assert.rejects(() => hook.run("text")); +}); + +test("a script that hangs past the timeout is killed and the call rejects", async () => { + const scriptPath = writeScript("sleep 5"); + const hook = createPostProcessHook({ getScriptPath: () => scriptPath, timeoutMs: 150 }); + const started = Date.now(); + await assert.rejects(() => hook.run("text"), /timed out/); + // Rejects close to the configured timeout, not after the script's full + // sleep - proves the process was actually killed, not just outraced. + assert.ok(Date.now() - started < 2000, "should not wait for the full sleep"); +}); + +test("a script that ignores stdin still resolves from its exit, no EPIPE hang", async () => { + const scriptPath = writeScript('echo "fixed output"'); + const hook = createPostProcessHook({ getScriptPath: () => scriptPath }); + const result = await hook.run("a very long input the script never reads"); + assert.equal(result, "fixed output"); +}); + +test("DEFAULT_TIMEOUT_MS is a sane, generous default", () => { + assert.equal(DEFAULT_TIMEOUT_MS, 10_000); +}); diff --git a/electron/preload.js b/electron/preload.js index 65e9924..71a0672 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -79,6 +79,8 @@ contextBridge.exposeInMainWorld("openstream", { setInputLanguage: (language) => ipcRenderer.invoke("settings:set-input-language", language), setHistoryRetentionDays: (days) => ipcRenderer.invoke("settings:set-history-retention-days", days), setHistoryMaxEntries: (count) => ipcRenderer.invoke("settings:set-history-max-entries", count), + setPostProcessScript: (scriptPath) => ipcRenderer.invoke("settings:set-post-process-script", scriptPath), + pickPostProcessScript: () => ipcRenderer.invoke("settings:pick-post-process-script"), }, vocabulary: { rescan: () => ipcRenderer.invoke("vocabulary:rescan"), diff --git a/electron/settingsStore.js b/electron/settingsStore.js index 7eff744..0a32069 100644 --- a/electron/settingsStore.js +++ b/electron/settingsStore.js @@ -44,6 +44,10 @@ const DEFAULT_SETTINGS = { // still bounded by historyMaxEntries. historyRetentionDays: DEFAULT_RETENTION_DAYS, historyMaxEntries: DEFAULT_HISTORY_MAX_ENTRIES, + // #259: null means no post-processing hook configured - the feature is + // opt-in, off by default. A path to an executable script the cleaned, + // finished text is piped through (stdin) before delivery. + postProcessScriptPath: null, }; // #257: a whole number of minutes, 0 (off) to a day. A day is already well @@ -126,6 +130,18 @@ function validateVocabularyProjectPath(projectPath) { } } +// #259: not checked against the filesystem here - a moved or deleted script +// is a runtime concern for postProcessHook.js (falls back to the un-hooked +// text, logged), not a settings-validation one. Keeps this consistent +// whether the path came from a live file picker or a hand-edited settings +// file. +function validatePostProcessScriptPath(scriptPath) { + if (scriptPath === null) return; + if (typeof scriptPath !== "string" || scriptPath.trim().length === 0) { + throw new Error("postProcessScriptPath must be null or a non-empty string"); + } +} + function validateWindowBounds(bounds) { if (bounds === null) return; if (typeof bounds !== "object") { @@ -273,6 +289,11 @@ function createSettingsStore({ filePath }) { return commit({ ...load(), vocabularyProjectPath: projectPath === null ? null : projectPath.trim() }); } + function setPostProcessScriptPath(scriptPath) { + validatePostProcessScriptPath(scriptPath); + return commit({ ...load(), postProcessScriptPath: scriptPath === null ? null : scriptPath.trim() }); + } + function setTermCorrections(entries) { validateTermCorrections(entries); return commit({ ...load(), termCorrections: normaliseTermCorrections(entries) }); @@ -351,6 +372,7 @@ function createSettingsStore({ filePath }) { setHotkey, setBreakSafeApps, setVocabularyProjectPath, + setPostProcessScriptPath, setTermCorrections, setCopyTranscriptToClipboard, setIdleUnloadMinutes, diff --git a/electron/settingsStore.test.js b/electron/settingsStore.test.js index 779707f..5442749 100644 --- a/electron/settingsStore.test.js +++ b/electron/settingsStore.test.js @@ -312,6 +312,25 @@ test("setVocabularyProjectPath persists a trimmed path and get() reflects it aft assert.equal(onDisk.vocabularyProjectPath, "/Users/me/code/myapp"); }); +test("#259: postProcessScriptPath defaults to null, accepts and trims a path, rejects junk", () => { + const filePath = tempFilePath(); + const store = createSettingsStore({ filePath }); + assert.equal(store.get().postProcessScriptPath, null); + + store.setPostProcessScriptPath(" /Users/me/scripts/hook.sh "); + assert.equal(store.get().postProcessScriptPath, "/Users/me/scripts/hook.sh"); + + const onDisk = JSON.parse(fs.readFileSync(filePath, "utf8")); + assert.equal(onDisk.postProcessScriptPath, "/Users/me/scripts/hook.sh"); + + store.setPostProcessScriptPath(null); + assert.equal(store.get().postProcessScriptPath, null); + + assert.throws(() => store.setPostProcessScriptPath(""), /postProcessScriptPath/); + assert.throws(() => store.setPostProcessScriptPath(" "), /postProcessScriptPath/); + assert.throws(() => store.setPostProcessScriptPath(42), /postProcessScriptPath/); +}); + test("setVocabularyProjectPath(null) clears a previously-set path", () => { const store = createSettingsStore({ filePath: tempFilePath() }); store.setVocabularyProjectPath("/Users/me/code/myapp"); diff --git a/src/openstreamBridge.d.ts b/src/openstreamBridge.d.ts index 8399fcd..9efdbd4 100644 --- a/src/openstreamBridge.d.ts +++ b/src/openstreamBridge.d.ts @@ -25,6 +25,8 @@ export type StoredSettings = { /** #264: 0 means never expire by time - still bounded by historyMaxEntries. */ historyRetentionDays: number; historyMaxEntries: number; + /** #259: a script the finished text is piped through before delivery, or null (off). */ + postProcessScriptPath: string | null; }; export type SetShortcutResult = @@ -119,6 +121,8 @@ declare global { setInputLanguage(language: string): Promise; setHistoryRetentionDays(days: number): Promise; setHistoryMaxEntries(count: number): Promise; + setPostProcessScript(scriptPath: string | null): Promise; + pickPostProcessScript(): Promise; }; vocabulary: { rescan(): Promise; diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index a2b64fa..c0041cd 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -363,6 +363,57 @@ function HistoryMaxEntriesSection() { ); } +function PostProcessSection() { + const [scriptPath, setScriptPath] = useState(null); + const [loaded, setLoaded] = useState(false); + + useEffect(() => { + window.openstream.settings.get().then((settings) => { + setScriptPath(settings.postProcessScriptPath); + setLoaded(true); + }); + }, []); + + function pick() { + window.openstream.settings.pickPostProcessScript().then((settings) => { + if (!settings) return; // the picker was cancelled + setScriptPath(settings.postProcessScriptPath); + }); + } + + function clear() { + window.openstream.settings + .setPostProcessScript(null) + .then((settings) => setScriptPath(settings.postProcessScriptPath)); + } + + return ( +
+

Post-processing hook

+

+ Pipe the finished text through your own script (stdin in, stdout out) before it's delivered - a + spellchecker, a personal glossary, a call out to a local model, anything you can script. It runs with full + access to whatever the script itself can do, so only point this at something you trust. If it fails, times + out, or goes missing, the text is delivered exactly as it would be without it. +

+
+ {scriptPath ? ( + + {scriptPath} + + + ) : ( + + )} +
+
+ ); +} + function StartupSection() { const [openAtLogin, setOpenAtLogin] = useState(null); @@ -432,6 +483,8 @@ export default function Settings() { + +