Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**:
Expand Down
18 changes: 17 additions & 1 deletion electron/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions electron/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
59 changes: 47 additions & 12 deletions electron/recordingHistoryStore.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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 = {
Expand All @@ -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) {
Expand All @@ -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 };
114 changes: 106 additions & 8 deletions electron/recordingHistoryStore.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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();
Expand All @@ -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);
Expand All @@ -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 });
}
Expand All @@ -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");
Expand All @@ -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"],
Expand Down Expand Up @@ -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(), []);
});
42 changes: 42 additions & 0 deletions electron/settingsStore.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -322,6 +358,8 @@ function createSettingsStore({ filePath }) {
setSoundCues,
setOverlayPosition,
setInputLanguage,
setHistoryRetentionDays,
setHistoryMaxEntries,
setWindowBounds,
onChange,
};
Expand All @@ -339,4 +377,8 @@ module.exports = {
validateIdleUnloadMinutes,
MAX_IDLE_UNLOAD_MINUTES,
validateInputLanguage,
validateHistoryRetentionDays,
validateHistoryMaxEntries,
MAX_HISTORY_RETENTION_DAYS,
MAX_HISTORY_MAX_ENTRIES,
};
Loading
Loading