From 5cdb32a6dd36a8d85194029671c81c905a6d4d05 Mon Sep 17 00:00:00 2001 From: Nabzx Date: Sat, 19 Sep 2026 10:22:05 +0400 Subject: [PATCH 1/5] recording-history: a live-configurable retention policy (#264) The count cap (previously a hardcoded MAX_ENTRIES=50) and a new time-based window are both read fresh via getRetentionPolicy, an injected adapter rather than constructor args - they're live Settings values, so a change takes effect on the very next write or read with no need to recreate the store. Purging runs on every write AND every read, not just at record() time - an expired entry is meant to be actually gone from disk once its window passes, not just hidden from whoever next asks. retentionDays: 0 means never expire by time, still bounded by the count cap. now is injectable for tests, same reasoning as settingsStore.js's filePath injection - no real clock needed to prove the expiry logic. Claude-Session: https://claude.ai/code/session_0144sxo5CJiyk99vaR7sgjuj --- electron/recordingHistoryStore.js | 59 ++++++++++--- electron/recordingHistoryStore.test.js | 114 +++++++++++++++++++++++-- 2 files changed, 153 insertions(+), 20 deletions(-) diff --git a/electron/recordingHistoryStore.js b/electron/recordingHistoryStore.js index dc5846f..b9a0d31 100644 --- a/electron/recordingHistoryStore.js +++ b/electron/recordingHistoryStore.js @@ -5,15 +5,29 @@ const crypto = require("crypto"); // #136: a lightweight local log of recent dictations - the text, whether it // delivered, and when - so a mis-delivery or a misheard word is recoverable // (copy the transcript back) without redictating. This is a recall aid, not -// an archive: fixed cap, oldest dropped first, no expiry policy - that's -// #264's job if it's ever wanted. +// an archive: capped and time-boxed, oldest dropped first. const MAX_ENTRIES = 50; +// #264: 0 means "keep forever" (still bounded by maxEntries) - a privacy +// -conscious default keeps some real expiry active out of the box rather +// than making the user find the setting first. +const DEFAULT_RETENTION_DAYS = 30; +const DAY_MS = 24 * 60 * 60 * 1000; // filePath is injected rather than derived from app.getPath("userData") // here, same reasoning as settingsStore.js: testable with a plain temp // file, no real Electron process needed. The caller (main.js) decides the // real path. -function createRecordingHistoryStore({ filePath, maxEntries = MAX_ENTRIES }) { +// +// #264: the cap and the time window are read fresh on every write/read via +// getRetentionPolicy, an adapter (same shape as dictationCoordinator.js's +// corrections/language options) rather than baked into the constructor - +// they're live Settings values, so a change takes effect on the very next +// dictation without recreating the store. now is injectable for tests. +function createRecordingHistoryStore({ + filePath, + getRetentionPolicy = () => ({ maxEntries: MAX_ENTRIES, retentionDays: DEFAULT_RETENTION_DAYS }), + now = () => Date.now(), +}) { let cache = null; const listeners = new Set(); @@ -58,18 +72,39 @@ function createRecordingHistoryStore({ filePath, maxEntries = MAX_ENTRIES }) { function commit(nextEntries) { persist(nextEntries); cache = nextEntries; - const entries = list(); + const entries = list({ skipPurge: true }); notify(entries); return entries; } - function list() { - return [...load()]; + // #264: applied on every read and write, not just at record() time - a + // retention window means old entries are gone even if the user never + // dictates again to trigger a cleanup, not just hidden once one does. + function purge(entries) { + const policy = getRetentionPolicy() || {}; + const maxEntries = Number.isInteger(policy.maxEntries) && policy.maxEntries >= 0 ? policy.maxEntries : MAX_ENTRIES; + const retentionDays = Number.isInteger(policy.retentionDays) && policy.retentionDays >= 0 ? policy.retentionDays : 0; + const cutoff = retentionDays > 0 ? now() - retentionDays * DAY_MS : null; + const withinWindow = cutoff === null ? entries : entries.filter((entry) => entry.at >= cutoff); + return withinWindow.slice(0, maxEntries); + } + + // list() (and therefore every read) actively purges and persists rather + // than just filtering the view - an expired entry is meant to be gone + // from disk, not merely hidden from whoever asked. skipPurge is for + // commit(), which just wrote the already-purged set and would otherwise + // re-purge (harmless, but a wasted read+maybe-write) on its own result. + function list({ skipPurge = false } = {}) { + const loaded = load(); + if (skipPurge) return [...loaded]; + const purged = purge(loaded); + if (purged.length !== loaded.length) { + persist(purged); + cache = purged; + } + return [...purged]; } - // A dictation that never produced text (no-speech, a failed transcription, - // a spoken "paste") never reaches this - only "delivered" and "held" - // outcomes carry real transcribed words worth recalling. function record({ text, delivered, bundleId, reason, at }) { if (typeof text !== "string" || !text.trim()) return list(); const entry = { @@ -78,9 +113,9 @@ function createRecordingHistoryStore({ filePath, maxEntries = MAX_ENTRIES }) { delivered: delivered === true, bundleId: typeof bundleId === "string" && bundleId ? bundleId : null, reason: typeof reason === "string" && reason ? reason : null, - at: typeof at === "number" ? at : Date.now(), + at: typeof at === "number" ? at : now(), }; - return commit([entry, ...load()].slice(0, maxEntries)); + return commit(purge([entry, ...load()])); } function remove(id) { @@ -103,4 +138,4 @@ function errorMessage(error) { return error instanceof Error ? error.message : String(error); } -module.exports = { createRecordingHistoryStore, MAX_ENTRIES }; +module.exports = { createRecordingHistoryStore, MAX_ENTRIES, DEFAULT_RETENTION_DAYS }; diff --git a/electron/recordingHistoryStore.test.js b/electron/recordingHistoryStore.test.js index 04bd86c..e6db6b3 100644 --- a/electron/recordingHistoryStore.test.js +++ b/electron/recordingHistoryStore.test.js @@ -3,7 +3,9 @@ const assert = require("node:assert/strict"); const fs = require("fs"); const os = require("os"); const path = require("path"); -const { createRecordingHistoryStore } = require("./recordingHistoryStore"); +const { createRecordingHistoryStore, DEFAULT_RETENTION_DAYS } = require("./recordingHistoryStore"); + +const DAY_MS = 24 * 60 * 60 * 1000; function tempFilePath() { return path.join(fs.mkdtempSync(path.join(os.tmpdir(), "openstream-history-")), "recording-history.json"); @@ -15,7 +17,12 @@ test("starts empty when no history file exists yet", () => { }); test("record adds an entry with defaults filled in", () => { - const store = createRecordingHistoryStore({ filePath: tempFilePath() }); + // #264: retentionDays: 0 - this test's fixed `at` (1970) predates any real + // retention window, and isn't what's under test here. + const store = createRecordingHistoryStore({ + filePath: tempFilePath(), + getRetentionPolicy: () => ({ maxEntries: 50, retentionDays: 0 }), + }); store.record({ text: "hello world", delivered: true, bundleId: "com.apple.Notes", at: 1000 }); const [entry] = store.list(); @@ -36,7 +43,7 @@ test("record ignores an empty or whitespace-only transcript", () => { }); test("newest entry is first", () => { - const store = createRecordingHistoryStore({ filePath: tempFilePath() }); + const store = createRecordingHistoryStore({ filePath: tempFilePath(), now: () => 5000 }); store.record({ text: "first", at: 1000 }); store.record({ text: "second", at: 2000 }); const texts = store.list().map((entry) => entry.text); @@ -51,8 +58,12 @@ test("held entries carry a reason and delivered: false", () => { assert.equal(entry.reason, "delivery could not proceed"); }); -test("caps at maxEntries, dropping the oldest", () => { - const store = createRecordingHistoryStore({ filePath: tempFilePath(), maxEntries: 3 }); +test("#264: caps at the configured maxEntries, dropping the oldest", () => { + const store = createRecordingHistoryStore({ + filePath: tempFilePath(), + getRetentionPolicy: () => ({ maxEntries: 3, retentionDays: 0 }), + now: () => 10_000, + }); for (let i = 0; i < 5; i++) { store.record({ text: `entry ${i}`, at: i }); } @@ -61,7 +72,11 @@ test("caps at maxEntries, dropping the oldest", () => { }); test("remove drops one entry by id", () => { - const store = createRecordingHistoryStore({ filePath: tempFilePath() }); + // #264: retentionDays: 0, same reasoning as the test above. + const store = createRecordingHistoryStore({ + filePath: tempFilePath(), + getRetentionPolicy: () => ({ maxEntries: 50, retentionDays: 0 }), + }); store.record({ text: "keep", at: 1 }); store.record({ text: "drop", at: 2 }); const target = store.list().find((entry) => entry.text === "drop"); @@ -81,11 +96,14 @@ test("clear empties the history", () => { }); test("persists to disk and a fresh store reads it back", () => { + // #264: retentionDays: 0, same reasoning as the two tests above - and + // both stores need it, since the reopened one also reads this entry back. const filePath = tempFilePath(); - const store = createRecordingHistoryStore({ filePath }); + const policy = () => ({ maxEntries: 50, retentionDays: 0 }); + const store = createRecordingHistoryStore({ filePath, getRetentionPolicy: policy }); store.record({ text: "persisted", at: 42 }); - const reopened = createRecordingHistoryStore({ filePath }); + const reopened = createRecordingHistoryStore({ filePath, getRetentionPolicy: policy }); assert.deepEqual( reopened.list().map((entry) => entry.text), ["persisted"], @@ -118,3 +136,83 @@ test("a listener that throws does not break the commit or other listeners", () = assert.deepEqual(seen, [1]); assert.equal(store.list().length, 1); }); + +test("#264: defaults to a 30-day retention window when no policy is given", () => { + assert.equal(DEFAULT_RETENTION_DAYS, 30); +}); + +test("#264: an entry older than retentionDays is dropped on the next record()", () => { + let clock = 0; + const store = createRecordingHistoryStore({ + filePath: tempFilePath(), + getRetentionPolicy: () => ({ maxEntries: 50, retentionDays: 7 }), + now: () => clock, + }); + store.record({ text: "old", at: 0 }); + clock = 8 * DAY_MS; + store.record({ text: "new", at: clock }); + + const texts = store.list().map((entry) => entry.text); + assert.deepEqual(texts, ["new"]); +}); + +test("#264: an entry older than retentionDays is dropped on list() alone, with no new record()", () => { + let clock = 0; + const store = createRecordingHistoryStore({ + filePath: tempFilePath(), + getRetentionPolicy: () => ({ maxEntries: 50, retentionDays: 7 }), + now: () => clock, + }); + store.record({ text: "old", at: 0 }); + assert.equal(store.list().length, 1); + + clock = 8 * DAY_MS; + assert.deepEqual(store.list(), []); +}); + +test("#264: list() persists the purge, so a fresh store also sees it gone", () => { + let clock = 0; + const filePath = tempFilePath(); + const store = createRecordingHistoryStore({ + filePath, + getRetentionPolicy: () => ({ maxEntries: 50, retentionDays: 7 }), + now: () => clock, + }); + store.record({ text: "old", at: 0 }); + + clock = 8 * DAY_MS; + store.list(); // triggers the purge-and-persist + + const reopened = createRecordingHistoryStore({ filePath, getRetentionPolicy: () => ({ maxEntries: 50, retentionDays: 7 }) }); + assert.deepEqual(reopened.list(), []); +}); + +test("#264: retentionDays: 0 means no time-based expiry, only the count cap applies", () => { + let clock = 0; + const store = createRecordingHistoryStore({ + filePath: tempFilePath(), + getRetentionPolicy: () => ({ maxEntries: 50, retentionDays: 0 }), + now: () => clock, + }); + store.record({ text: "ancient", at: 0 }); + clock = 365 * DAY_MS; + + assert.deepEqual( + store.list().map((entry) => entry.text), + ["ancient"], + ); +}); + +test("#264: a live policy change takes effect on the very next call, no store recreation needed", () => { + let policy = { maxEntries: 50, retentionDays: 0 }; + const store = createRecordingHistoryStore({ + filePath: tempFilePath(), + getRetentionPolicy: () => policy, + now: () => 100 * DAY_MS, + }); + store.record({ text: "old", at: 0 }); + assert.equal(store.list().length, 1); + + policy = { maxEntries: 50, retentionDays: 7 }; + assert.deepEqual(store.list(), []); +}); From 1940fd4f12e705091c391122d4561565efe42e63 Mon Sep 17 00:00:00 2001 From: Nabzx Date: Sat, 19 Sep 2026 10:22:21 +0400 Subject: [PATCH 2/5] settings: historyRetentionDays and historyMaxEntries fields (#264) Defaults come from recordingHistoryStore.js's own constants (DEFAULT_RETENTION_DAYS=30, MAX_ENTRIES=50) so the two files can't drift apart. 30 days by default rather than "forever" - the issue's own framing is that privacy-sensitive users want a real expiry on by default, not a setting they have to go find first. Claude-Session: https://claude.ai/code/session_0144sxo5CJiyk99vaR7sgjuj --- electron/settingsStore.js | 42 ++++++++++++++++++++++++++++++++++ electron/settingsStore.test.js | 25 ++++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/electron/settingsStore.js b/electron/settingsStore.js index 068c23f..7eff744 100644 --- a/electron/settingsStore.js +++ b/electron/settingsStore.js @@ -4,6 +4,7 @@ const { DEFAULT_BREAK_SAFE_BUNDLE_IDS } = require("./breakSafety"); const { STANDALONE_OPTION_KEY_CODE, isSupportedSingleKeyShortcut } = require("./hotkeyDefinitions"); const { OVERLAY_POSITIONS, DEFAULT_OVERLAY_POSITION } = require("./overlayPosition"); const { isSupportedLanguage } = require("./languages"); +const { MAX_ENTRIES: DEFAULT_HISTORY_MAX_ENTRIES, DEFAULT_RETENTION_DAYS } = require("./recordingHistoryStore"); // Matches hotkeyHelper.js's standalone Option default and breakSafety.js's // own default allow-list. Existing settings are read as-is below so this @@ -39,6 +40,10 @@ const DEFAULT_SETTINGS = { // #252: transcription input language. "en" (unchanged behaviour), "auto" // to let the model detect it, or a specific code from languages.js. inputLanguage: "en", + // #264: recording-history retention. 0 days means "never expire by time" - + // still bounded by historyMaxEntries. + historyRetentionDays: DEFAULT_RETENTION_DAYS, + historyMaxEntries: DEFAULT_HISTORY_MAX_ENTRIES, }; // #257: a whole number of minutes, 0 (off) to a day. A day is already well @@ -64,6 +69,27 @@ function validateInputLanguage(language) { } } +// #264: a year is already well past "why does this app still remember what +// I said months ago" territory - the cap is a guard against a nonsense +// hand-edited value, same reasoning as MAX_IDLE_UNLOAD_MINUTES. +const MAX_HISTORY_RETENTION_DAYS = 365; +// #264: matches recordingHistoryStore.js's own sanity floor - a cap of 0 +// would mean every dictation is purged the instant it's recorded, which +// defeats the point of history rather than limiting it. +const MAX_HISTORY_MAX_ENTRIES = 1000; + +function validateHistoryRetentionDays(days) { + if (!Number.isInteger(days) || days < 0 || days > MAX_HISTORY_RETENTION_DAYS) { + throw new Error(`historyRetentionDays must be a whole number of days between 0 and ${MAX_HISTORY_RETENTION_DAYS}`); + } +} + +function validateHistoryMaxEntries(count) { + if (!Number.isInteger(count) || count < 1 || count > MAX_HISTORY_MAX_ENTRIES) { + throw new Error(`historyMaxEntries must be a whole number between 1 and ${MAX_HISTORY_MAX_ENTRIES}`); + } +} + const VALID_MODIFIERS = new Set(["cmd", "shift", "alt", "ctrl"]); function errorMessage(error) { @@ -288,6 +314,16 @@ function createSettingsStore({ filePath }) { return commit({ ...load(), inputLanguage: language }); } + function setHistoryRetentionDays(days) { + validateHistoryRetentionDays(days); + return commit({ ...load(), historyRetentionDays: days }); + } + + function setHistoryMaxEntries(count) { + validateHistoryMaxEntries(count); + return commit({ ...load(), historyMaxEntries: count }); + } + function setWindowBounds(bounds) { validateWindowBounds(bounds); // Only the four geometry keys are kept - a caller passing a whole @@ -322,6 +358,8 @@ function createSettingsStore({ filePath }) { setSoundCues, setOverlayPosition, setInputLanguage, + setHistoryRetentionDays, + setHistoryMaxEntries, setWindowBounds, onChange, }; @@ -339,4 +377,8 @@ module.exports = { validateIdleUnloadMinutes, MAX_IDLE_UNLOAD_MINUTES, validateInputLanguage, + validateHistoryRetentionDays, + validateHistoryMaxEntries, + MAX_HISTORY_RETENTION_DAYS, + MAX_HISTORY_MAX_ENTRIES, }; diff --git a/electron/settingsStore.test.js b/electron/settingsStore.test.js index bfe6668..779707f 100644 --- a/electron/settingsStore.test.js +++ b/electron/settingsStore.test.js @@ -276,6 +276,31 @@ test("#252: inputLanguage defaults to en, accepts auto and codes, rejects junk", assert.throws(() => store.setInputLanguage(""), /inputLanguage/); }); +test("#264: historyRetentionDays defaults to 30, accepts a whole number of days, rejects junk", () => { + const store = createSettingsStore({ filePath: tempFilePath() }); + assert.equal(store.get().historyRetentionDays, 30); + store.setHistoryRetentionDays(0); + assert.equal(store.get().historyRetentionDays, 0); + store.setHistoryRetentionDays(365); + assert.equal(store.get().historyRetentionDays, 365); + assert.throws(() => store.setHistoryRetentionDays(366), /historyRetentionDays/); + assert.throws(() => store.setHistoryRetentionDays(-1), /historyRetentionDays/); + assert.throws(() => store.setHistoryRetentionDays(1.5), /historyRetentionDays/); + assert.throws(() => store.setHistoryRetentionDays("30"), /historyRetentionDays/); +}); + +test("#264: historyMaxEntries defaults to 50, accepts a whole number, rejects junk", () => { + const store = createSettingsStore({ filePath: tempFilePath() }); + assert.equal(store.get().historyMaxEntries, 50); + store.setHistoryMaxEntries(1); + assert.equal(store.get().historyMaxEntries, 1); + store.setHistoryMaxEntries(1000); + assert.equal(store.get().historyMaxEntries, 1000); + assert.throws(() => store.setHistoryMaxEntries(0), /historyMaxEntries/); + assert.throws(() => store.setHistoryMaxEntries(1001), /historyMaxEntries/); + assert.throws(() => store.setHistoryMaxEntries(2.5), /historyMaxEntries/); +}); + test("setVocabularyProjectPath persists a trimmed path and get() reflects it afterwards", () => { const filePath = tempFilePath(); const store = createSettingsStore({ filePath }); From 1ef8352b8366928d67a6c515995c710d8f78f160 Mon Sep 17 00:00:00 2001 From: Nabzx Date: Sat, 19 Sep 2026 10:22:35 +0400 Subject: [PATCH 3/5] wire the retention policy into the history store, IPC + bridge (#264) recordingHistoryStore's getRetentionPolicy reads settingsStore.get() fresh on every call - no caching, no extra onChange subscription needed, since the store already reads it live per the previous commit. IPC + bridge: settings:set-history-retention-days, settings:set-history-max-entries. Claude-Session: https://claude.ai/code/session_0144sxo5CJiyk99vaR7sgjuj --- electron/main.js | 18 +++++++++++++++++- electron/preload.js | 2 ++ src/openstreamBridge.d.ts | 5 +++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/electron/main.js b/electron/main.js index c76d2db..f19a45b 100644 --- a/electron/main.js +++ b/electron/main.js @@ -1101,6 +1101,16 @@ ipcMain.handle("settings:set-input-language", (event, language) => { return settingsStore.setInputLanguage(language); }); +ipcMain.handle("settings:set-history-retention-days", (event, days) => { + // #264: the store validates; recordingHistoryStore reads this fresh on + // its next write or read, no extra wiring needed here. + return settingsStore.setHistoryRetentionDays(days); +}); + +ipcMain.handle("settings:set-history-max-entries", (event, count) => { + return settingsStore.setHistoryMaxEntries(count); +}); + // #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. @@ -1192,9 +1202,15 @@ app.whenReady().then(() => { const launchedAtLogin = process.platform === "darwin" && app.getLoginItemSettings().wasOpenedAtLogin; // Regular Dock app (issue #209) - no app.dock.hide(). settingsStore = createSettingsStore({ filePath: settingsPath }); - // #136: same userData directory as settings, its own file. + // #136: same userData directory as settings, its own file. #264: the + // retention policy is read fresh from settings on every write/read, so a + // change in Settings takes effect immediately - see recordingHistoryStore.js. recordingHistoryStore = createRecordingHistoryStore({ filePath: path.join(app.getPath("userData"), "recording-history.json"), + getRetentionPolicy: () => ({ + maxEntries: settingsStore.get().historyMaxEntries, + retentionDays: settingsStore.get().historyRetentionDays, + }), }); recordingHistoryStore.onChange((entries) => { if (win && !win.isDestroyed()) win.webContents.send("recording-history:changed", entries); diff --git a/electron/preload.js b/electron/preload.js index 3e72ad7..65e9924 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -77,6 +77,8 @@ contextBridge.exposeInMainWorld("openstream", { setSoundCues: (enabled) => ipcRenderer.invoke("settings:set-sound-cues", enabled), setOverlayPosition: (position) => ipcRenderer.invoke("settings:set-overlay-position", position), 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), }, vocabulary: { rescan: () => ipcRenderer.invoke("vocabulary:rescan"), diff --git a/src/openstreamBridge.d.ts b/src/openstreamBridge.d.ts index c478e54..8399fcd 100644 --- a/src/openstreamBridge.d.ts +++ b/src/openstreamBridge.d.ts @@ -22,6 +22,9 @@ export type StoredSettings = { overlayPosition: OverlayPosition; /** #252: "auto", "en", or another supported language code. */ inputLanguage: string; + /** #264: 0 means never expire by time - still bounded by historyMaxEntries. */ + historyRetentionDays: number; + historyMaxEntries: number; }; export type SetShortcutResult = @@ -114,6 +117,8 @@ declare global { setSoundCues(enabled: boolean): Promise; setOverlayPosition(position: OverlayPosition): Promise; setInputLanguage(language: string): Promise; + setHistoryRetentionDays(days: number): Promise; + setHistoryMaxEntries(count: number): Promise; }; vocabulary: { rescan(): Promise; From d22203f9b80b55690c264b49e45cc774f18ff25c Mon Sep 17 00:00:00 2001 From: Nabzx Date: Sat, 19 Sep 2026 10:22:49 +0400 Subject: [PATCH 4/5] settings UI: retention controls, and say what's active in History (#264) Two selects, same preset-plus-custom-value pattern as idle unload - "Keep recording history for" (0/7/30/90/365 days) and "Recording history size" (10/25/50/100/250 entries). Independent limits, whichever is hit first applies. History's own header used to hardcode "last 50" - now reflects whatever the two settings actually are. Claude-Session: https://claude.ai/code/session_0144sxo5CJiyk99vaR7sgjuj --- src/pages/History.tsx | 22 +++++++-- src/pages/Settings.tsx | 102 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 3 deletions(-) diff --git a/src/pages/History.tsx b/src/pages/History.tsx index 8c8554c..c45ac5e 100644 --- a/src/pages/History.tsx +++ b/src/pages/History.tsx @@ -20,16 +20,32 @@ function formatRelativeTime(at: number, now: number): string { return new Date(at).toLocaleDateString(); } +function retentionSummary(retentionDays: number, maxEntries: number): string { + const countPart = `your last ${maxEntries}`; + if (retentionDays === 0) return `Kept forever, up to ${countPart} dictations`; + const timePart = retentionDays === 1 ? "1 day" : `${retentionDays} days`; + return `Kept for ${timePart}, up to ${countPart} - whichever comes first`; +} + export default function History() { const [entries, setEntries] = useState(null); const [now, setNow] = useState(() => Date.now()); const [copiedId, setCopiedId] = useState(null); + // #264: only for the description below - the retention policy itself is + // enforced in the main process regardless of whether this text loaded. + const [retention, setRetention] = useState<{ days: number; max: number } | null>(null); useEffect(() => { window.openstream.recordingHistory.get().then(setEntries); return window.openstream.onRecordingHistory(setEntries); }, []); + useEffect(() => { + window.openstream.settings + .get() + .then((settings) => setRetention({ days: settings.historyRetentionDays, max: settings.historyMaxEntries })); + }, []); + useEffect(() => { const timer = setInterval(() => setNow(Date.now()), RELATIVE_TIME_TICK_MS); return () => clearInterval(timer); @@ -57,9 +73,9 @@ export default function History() {

Recent dictations

- {/* MAX_ENTRIES in electron/recordingHistoryStore.js - keep in sync. */} - Your last 50 finished dictations, held locally on this Mac - not synced, not sent anywhere. A misheard - word or a mis-delivery is recoverable here without redictating. + {retention ? retentionSummary(retention.days, retention.max) : "Your recent dictations"}, held locally on + this Mac - not synced, not sent anywhere. A misheard word or a mis-delivery is recoverable here without + redictating. Change how long these stick around in Settings.

diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index cb9ec33..a2b64fa 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -265,6 +265,104 @@ function OverlayPositionSection() { ); } +// #264: mirrors electron/recordingHistoryStore.js's DEFAULT_RETENTION_DAYS. +const HISTORY_RETENTION_OPTIONS = [ + { value: 0, label: "Forever" }, + { value: 7, label: "7 days" }, + { value: 30, label: "30 days" }, + { value: 90, label: "90 days" }, + { value: 365, label: "1 year" }, +]; + +function HistoryRetentionDaysSection() { + const [days, setDays] = useState(null); + + useEffect(() => { + window.openstream.settings.get().then((settings) => setDays(settings.historyRetentionDays)); + }, []); + + // A saved custom value that isn't one of the presets still needs a row. + const options = + days !== null && !HISTORY_RETENTION_OPTIONS.some((o) => o.value === days) + ? [...HISTORY_RETENTION_OPTIONS, { value: days, label: `${days} days` }] + : HISTORY_RETENTION_OPTIONS; + + return ( +
+

Keep recording history for

+

+ How long a dictation stays in the History tab before it's deleted automatically. Only the text is ever + kept - your audio is never saved past the dictation itself. +

+
+ +
+
+ ); +} + +// #264: mirrors electron/recordingHistoryStore.js's MAX_ENTRIES. +const HISTORY_MAX_ENTRIES_OPTIONS = [10, 25, 50, 100, 250]; + +function HistoryMaxEntriesSection() { + const [count, setCount] = useState(null); + + useEffect(() => { + window.openstream.settings.get().then((settings) => setCount(settings.historyMaxEntries)); + }, []); + + const options = count !== null && !HISTORY_MAX_ENTRIES_OPTIONS.includes(count) + ? [...HISTORY_MAX_ENTRIES_OPTIONS, count].sort((a, b) => a - b) + : HISTORY_MAX_ENTRIES_OPTIONS; + + return ( +
+

Recording history size

+

+ The most dictations to keep at once, oldest dropped first once you're over. Independent of how long + they're kept - whichever limit is hit first applies. +

+
+ +
+
+ ); +} + function StartupSection() { const [openAtLogin, setOpenAtLogin] = useState(null); @@ -330,6 +428,10 @@ export default function Settings() { + + + + From ef81568afa47a4fda6c0f6ccaead664dca25abe0 Mon Sep 17 00:00:00 2001 From: Nabzx Date: Sat, 19 Sep 2026 10:22:55 +0400 Subject: [PATCH 5/5] docs: update the Recording history glossary entry for #264 Claude-Session: https://claude.ai/code/session_0144sxo5CJiyk99vaR7sgjuj --- CONTEXT.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTEXT.md b/CONTEXT.md index 42fae1d..2633134 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -27,7 +27,7 @@ The finished text from a completed recording that could not be placed at the cur _Avoid_: Failed dictation, lost text **Recording history** (#136): -A local, persistent log of recent dictations - delivered and held alike - kept so a mis-delivery or a misheard word is recoverable without redictating. Distinct from a Held result: a Held result is one live entry in the overlay that disappears on copy or dismiss; history is a running log a delivered dictation joins too, capped and FIFO-trimmed rather than curated. +A local, persistent log of recent dictations - delivered and held alike - kept so a mis-delivery or a misheard word is recoverable without redictating. Distinct from a Held result: a Held result is one live entry in the overlay that disappears on copy or dismiss; history is a running log a delivered dictation joins too, bounded by a retention policy (#264) rather than curated - a configurable count cap and time window, both user-adjustable in Settings, whichever is hit first. _Avoid_: Transcript log, clipboard history **Voice edit**: