Skip to content

Commit 9b26f70

Browse files
v0.8.0: configurable meeting popup triggers + Comet browser support
Two ship-worthy improvements over v0.7.0's meeting capture pipeline: 1. The popup that fires when a meeting is detected is now fully user- configurable. A zod-validated config blob in the archive's `config` table drives a pure decision function; the daemon hot-reloads it via a new `config_reload` socket op so changes take effect without a restart. 2. Perplexity Comet joins Safari, Chrome, Brave, Arc, Vivaldi, and Edge as a recognised meeting-capable browser. The Swift helper now grants Apple Events permission to Comet alongside the others, and the browser-URL probe knows to ask Comet for its frontmost-tab URL using the Chromium-family AppleScript dialect. ────────────────────────────────────────────────────────────────────── Configurable popup triggers ────────────────────────────────────────────────────────────────────── src/meeting/config.ts — single zod schema persisted as one JSON blob under the `meeting_popup_config` config key. Defaults match v0.7.0 exactly (threshold=HIGH, no schedule, empty allow/block lists) so upgraders see no behaviour change unless they opt in. src/meeting/decide-popup.ts — pure `decidePopup(signal, config, now) → {fire, reason}`. Gate precedence: NONE confidence → never fire allowlist match → force-fire (overrides everything else) schedule.enabled && outside window → suppress blocklist match → suppress threshold gate → HIGH | MEDIUM | NEVER Schedule windows are tz-aware (`Intl.DateTimeFormat`), support midnight-crossing ranges (22:00→02:00), and the day-of-week code on a wrapping window names the *start* day. src/meeting/daemon.ts — caches the config in-memory; reloads on the new `{op:"config_reload"}` socket op. Detector edges flow through `decidePopup` before any popup attempt, with the decision reason logged on every gate. src/commands/meeting-config.ts — pure module backing every subcommand; all writes go through `writeConfig` so the schema (and URL-regex compilation) runs at the persistence boundary, not at decision time. src/cli.ts — registers the `swrag meeting config …` tree: get | set <path> <value> | reset allow-app | unallow-app | block-app | unblock-app <bundle_id> allow-url | unallow-url | block-url | unblock-url <regex> schedule add <days> <HH:MM>-<HH:MM> | clear | enable | disable export <path> | import <path> Every successful mutation fires `config_reload` on the daemon socket (daemon-client fallback: if no daemon is running, the change is persisted; the next start picks it up). ────────────────────────────────────────────────────────────────────── Comet browser support ────────────────────────────────────────────────────────────────────── swift-helper/Sources/SwragHelper/PermissionsCheck.swift — adds `ai.perplexity.comet` to the supported-browser list and maps it to the display name "Comet" for the AppleScript `tell application` shape. src/mac/browser-url.ts — adds the Chromium-family dialect entry for Comet (`URL of active tab of front window`). src/meeting/detect.ts — Comet joins the BROWSERS set so the detector treats it as a meeting-capable browser when scoring confidence. The helper binary at vendor/swrag-helper-darwin-universal is rebuilt for arm64 + x86_64 with the new bundle id baked in (1182552 bytes). ────────────────────────────────────────────────────────────────────── Tests ────────────────────────────────────────────────────────────────────── tests/meeting/config.test.ts — schema defaults + DB I/O tests/meeting/decide-popup.test.ts — threshold matrix, allow/block, schedule windows, precedence tests/meeting/daemon-api.test.ts — config_reload socket op, in-memory cache, end-to-end threshold=NEVER suppresses popup tests/commands/meeting-config.test.ts — dotted-path set, day/time parsers, export/import tests/meeting/detect.test.ts — Comet recognised as browser tests/mac/{browser-url,helper}.test.ts — Comet AppleScript dialect Suite: 368 tests / 900 expects / 0 fail. typecheck clean. lint 2 pre-existing warnings (no new ones).
1 parent 0dd2495 commit 9b26f70

18 files changed

Lines changed: 2534 additions & 15 deletions

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "superwhisper-rag",
3-
"version": "0.7.0",
3+
"version": "0.8.0",
44
"description": "Local SQL archive for Super Whisper dictation history with full-text + semantic search.",
55
"type": "module",
66
"private": true,

src/cli.ts

Lines changed: 326 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { existsSync, realpathSync, statSync } from "node:fs";
33
import { resolve as resolvePath } from "node:path";
44
import { VERSION } from "./config.ts";
55
import { getEnv } from "./env.ts";
6-
import { error, info } from "./log.ts";
6+
import { error, info, warn } from "./log.ts";
77
import { resolvePaths, type ResolvedPaths } from "./paths.ts";
88
import type { Env } from "./schemas.ts";
99
import { runBootstrap } from "./commands/bootstrap.ts";
@@ -26,6 +26,18 @@ import {
2626
import { runDaemonForeground } from "./meeting/daemon.ts";
2727
import { callDaemon, DaemonUnavailableError, isDaemonRunning } from "./meeting/daemon-client.ts";
2828
import { helperBinaryPath, getPermissions, spawnEventsHelper } from "./mac/helper.ts";
29+
import {
30+
cmdExport as meetingConfigExport,
31+
cmdGet as meetingConfigGet,
32+
cmdImport as meetingConfigImport,
33+
cmdListOp as meetingConfigListOp,
34+
cmdReset as meetingConfigReset,
35+
cmdScheduleAdd as meetingConfigScheduleAdd,
36+
cmdScheduleClear as meetingConfigScheduleClear,
37+
cmdScheduleEnable as meetingConfigScheduleEnable,
38+
cmdSet as meetingConfigSet,
39+
formatConfig as meetingConfigFormat,
40+
} from "./commands/meeting-config.ts";
2941

3042
// The CLI surface is intentionally tiny — zero flags. Everything that used
3143
// to be a flag is an env var, parsed and validated through `getEnv()`.
@@ -1019,13 +1031,326 @@ const meetingDisableWatcherCmd = defineCommand({
10191031
},
10201032
});
10211033

1034+
/* -------------------------------------------------------------------------- */
1035+
/* meeting config — popup trigger configuration (v0.8.0) */
1036+
/* -------------------------------------------------------------------------- */
1037+
1038+
/**
1039+
* Fire `{op:"config_reload"}` on the daemon socket so the running
1040+
* watcher picks up the change in-process. If the daemon isn't
1041+
* running, the change is still persisted (every CLI mutation writes
1042+
* through the DB) — we just emit a note so the user knows the
1043+
* reload is deferred.
1044+
*
1045+
* Wrapped so a daemon-side error becomes a CLI warning rather than
1046+
* a hard failure: the persisted write succeeded, and the next
1047+
* daemon restart will pick it up.
1048+
*/
1049+
async function reloadDaemonConfig(): Promise<void> {
1050+
if (!(await isDaemonRunning())) {
1051+
info("(daemon not running — change persisted; will load on next start)");
1052+
return;
1053+
}
1054+
try {
1055+
const r = await callDaemon<{ ok?: true; error?: string }>({ op: "config_reload" });
1056+
if (r.error) {
1057+
warn(`config_reload returned error: ${r.error}`);
1058+
}
1059+
} catch (e) {
1060+
if (e instanceof DaemonUnavailableError) {
1061+
info("(daemon went away between probe and reload — change persisted)");
1062+
return;
1063+
}
1064+
warn(`config_reload failed: ${e instanceof Error ? e.message : String(e)}`);
1065+
}
1066+
}
1067+
1068+
const meetingConfigGetCmd = defineCommand({
1069+
meta: { name: "get", description: "Print the meeting popup config as JSON." },
1070+
args: {},
1071+
run() {
1072+
const c = meetingConfigGet(ctx().paths.archive);
1073+
process.stdout.write(meetingConfigFormat(c));
1074+
},
1075+
});
1076+
1077+
const meetingConfigSetCmd = defineCommand({
1078+
meta: {
1079+
name: "set",
1080+
description:
1081+
"Set a config value by dotted path (e.g. `set threshold MEDIUM`, `set schedule.enabled true`). Arrays are replaced wholesale; pass `[]` to clear.",
1082+
},
1083+
args: {
1084+
path: { type: "positional", required: true, description: "Dotted path (e.g. threshold, schedule.enabled)" },
1085+
value: { type: "positional", required: true, description: "New value (true/false → bool, integer → number, else string)" },
1086+
},
1087+
async run({ args }) {
1088+
const dottedPath = asString(args.path);
1089+
const value = asString(args.value);
1090+
if (!dottedPath) {
1091+
error("missing required positional: path");
1092+
process.exit(2);
1093+
}
1094+
if (value == null) {
1095+
error("missing required positional: value");
1096+
process.exit(2);
1097+
}
1098+
try {
1099+
const c = meetingConfigSet(ctx().paths.archive, dottedPath, value);
1100+
process.stdout.write(meetingConfigFormat(c));
1101+
} catch (e) {
1102+
error(e instanceof Error ? e.message : String(e));
1103+
process.exit(1);
1104+
}
1105+
await reloadDaemonConfig();
1106+
},
1107+
});
1108+
1109+
const meetingConfigResetCmd = defineCommand({
1110+
meta: { name: "reset", description: "Reset the meeting popup config to defaults." },
1111+
args: {},
1112+
async run() {
1113+
const c = meetingConfigReset(ctx().paths.archive);
1114+
process.stdout.write(meetingConfigFormat(c));
1115+
await reloadDaemonConfig();
1116+
},
1117+
});
1118+
1119+
/**
1120+
* Tiny factory for the eight list-op subcommands. Each takes one
1121+
* positional and threads through to `cmdListOp`. We keep them as
1122+
* separate citty commands (rather than one with a `kind` flag) so
1123+
* `swrag meeting config --help` lists the user-visible verbs.
1124+
*/
1125+
function listOpCmd(opts: {
1126+
name: string;
1127+
description: string;
1128+
argLabel: string;
1129+
kind:
1130+
| "allow-app"
1131+
| "unallow-app"
1132+
| "block-app"
1133+
| "unblock-app"
1134+
| "allow-url"
1135+
| "unallow-url"
1136+
| "block-url"
1137+
| "unblock-url";
1138+
}) {
1139+
return defineCommand({
1140+
meta: { name: opts.name, description: opts.description },
1141+
args: {
1142+
value: { type: "positional", required: true, description: opts.argLabel },
1143+
},
1144+
async run({ args }) {
1145+
const value = asString(args.value);
1146+
if (!value) {
1147+
error(`missing required positional: ${opts.argLabel}`);
1148+
process.exit(2);
1149+
}
1150+
try {
1151+
const c = meetingConfigListOp(ctx().paths.archive, opts.kind, value);
1152+
process.stdout.write(meetingConfigFormat(c));
1153+
} catch (e) {
1154+
error(e instanceof Error ? e.message : String(e));
1155+
process.exit(1);
1156+
}
1157+
await reloadDaemonConfig();
1158+
},
1159+
});
1160+
}
1161+
1162+
const meetingConfigAllowAppCmd = listOpCmd({
1163+
name: "allow-app",
1164+
description: "Add a bundle id to allowlist.bundle_ids (always-fire). Idempotent.",
1165+
argLabel: "bundle_id",
1166+
kind: "allow-app",
1167+
});
1168+
const meetingConfigUnallowAppCmd = listOpCmd({
1169+
name: "unallow-app",
1170+
description: "Remove a bundle id from allowlist.bundle_ids.",
1171+
argLabel: "bundle_id",
1172+
kind: "unallow-app",
1173+
});
1174+
const meetingConfigBlockAppCmd = listOpCmd({
1175+
name: "block-app",
1176+
description: "Add a bundle id to blocklist.bundle_ids (suppress popup).",
1177+
argLabel: "bundle_id",
1178+
kind: "block-app",
1179+
});
1180+
const meetingConfigUnblockAppCmd = listOpCmd({
1181+
name: "unblock-app",
1182+
description: "Remove a bundle id from blocklist.bundle_ids.",
1183+
argLabel: "bundle_id",
1184+
kind: "unblock-app",
1185+
});
1186+
const meetingConfigAllowUrlCmd = listOpCmd({
1187+
name: "allow-url",
1188+
description: "Add a regex to allowlist.url_patterns (always-fire on browser URL match).",
1189+
argLabel: "regex",
1190+
kind: "allow-url",
1191+
});
1192+
const meetingConfigUnallowUrlCmd = listOpCmd({
1193+
name: "unallow-url",
1194+
description: "Remove a regex from allowlist.url_patterns.",
1195+
argLabel: "regex",
1196+
kind: "unallow-url",
1197+
});
1198+
const meetingConfigBlockUrlCmd = listOpCmd({
1199+
name: "block-url",
1200+
description: "Add a regex to blocklist.url_patterns (suppress popup on browser URL match).",
1201+
argLabel: "regex",
1202+
kind: "block-url",
1203+
});
1204+
const meetingConfigUnblockUrlCmd = listOpCmd({
1205+
name: "unblock-url",
1206+
description: "Remove a regex from blocklist.url_patterns.",
1207+
argLabel: "regex",
1208+
kind: "unblock-url",
1209+
});
1210+
1211+
const meetingConfigScheduleAddCmd = defineCommand({
1212+
meta: {
1213+
name: "add",
1214+
description:
1215+
'Append a schedule window. Examples: `add mon-fri 09:00-18:00`, `add sat,sun 10:00-14:00`. Does not enable the schedule.',
1216+
},
1217+
args: {
1218+
days: { type: "positional", required: true, description: "Day spec: mon | mon-fri | mon,wed,fri" },
1219+
time: { type: "positional", required: true, description: "Time range: HH:MM-HH:MM" },
1220+
},
1221+
async run({ args }) {
1222+
const days = asString(args.days);
1223+
const time = asString(args.time);
1224+
if (!days || !time) {
1225+
error("missing required positional(s): days and time");
1226+
process.exit(2);
1227+
}
1228+
try {
1229+
const c = meetingConfigScheduleAdd(ctx().paths.archive, days, time);
1230+
process.stdout.write(meetingConfigFormat(c));
1231+
} catch (e) {
1232+
error(e instanceof Error ? e.message : String(e));
1233+
process.exit(1);
1234+
}
1235+
await reloadDaemonConfig();
1236+
},
1237+
});
1238+
1239+
const meetingConfigScheduleClearCmd = defineCommand({
1240+
meta: { name: "clear", description: "Remove every window from schedule.windows." },
1241+
args: {},
1242+
async run() {
1243+
const c = meetingConfigScheduleClear(ctx().paths.archive);
1244+
process.stdout.write(meetingConfigFormat(c));
1245+
await reloadDaemonConfig();
1246+
},
1247+
});
1248+
1249+
const meetingConfigScheduleEnableCmd = defineCommand({
1250+
meta: { name: "enable", description: "Set schedule.enabled = true." },
1251+
args: {},
1252+
async run() {
1253+
const c = meetingConfigScheduleEnable(ctx().paths.archive, true);
1254+
process.stdout.write(meetingConfigFormat(c));
1255+
await reloadDaemonConfig();
1256+
},
1257+
});
1258+
1259+
const meetingConfigScheduleDisableCmd = defineCommand({
1260+
meta: { name: "disable", description: "Set schedule.enabled = false." },
1261+
args: {},
1262+
async run() {
1263+
const c = meetingConfigScheduleEnable(ctx().paths.archive, false);
1264+
process.stdout.write(meetingConfigFormat(c));
1265+
await reloadDaemonConfig();
1266+
},
1267+
});
1268+
1269+
const meetingConfigScheduleCmd = defineCommand({
1270+
meta: { name: "schedule", description: "Manage schedule windows (add | clear | enable | disable)." },
1271+
subCommands: {
1272+
add: meetingConfigScheduleAddCmd,
1273+
clear: meetingConfigScheduleClearCmd,
1274+
enable: meetingConfigScheduleEnableCmd,
1275+
disable: meetingConfigScheduleDisableCmd,
1276+
},
1277+
});
1278+
1279+
const meetingConfigExportCmd = defineCommand({
1280+
meta: { name: "export", description: "Write the current config to a JSON file." },
1281+
args: {
1282+
path: { type: "positional", required: true, description: "Output file path" },
1283+
},
1284+
run({ args }) {
1285+
const path = asString(args.path);
1286+
if (!path) {
1287+
error("missing required positional: path");
1288+
process.exit(2);
1289+
}
1290+
const out = resolvePath(path);
1291+
const r = meetingConfigExport(ctx().paths.archive, out);
1292+
info(`exported config → ${r.path}`);
1293+
},
1294+
});
1295+
1296+
const meetingConfigImportCmd = defineCommand({
1297+
meta: {
1298+
name: "import",
1299+
description: "Load and validate a JSON config from disk, replacing the current one.",
1300+
},
1301+
args: {
1302+
path: { type: "positional", required: true, description: "Input file path" },
1303+
},
1304+
async run({ args }) {
1305+
const path = asString(args.path);
1306+
if (!path) {
1307+
error("missing required positional: path");
1308+
process.exit(2);
1309+
}
1310+
const inp = resolvePath(path);
1311+
try {
1312+
const c = meetingConfigImport(ctx().paths.archive, inp);
1313+
process.stdout.write(meetingConfigFormat(c));
1314+
} catch (e) {
1315+
error(e instanceof Error ? e.message : String(e));
1316+
process.exit(1);
1317+
}
1318+
await reloadDaemonConfig();
1319+
},
1320+
});
1321+
1322+
const meetingConfigCmd = defineCommand({
1323+
meta: {
1324+
name: "config",
1325+
description:
1326+
"Configure the meeting popup triggers (threshold, schedule, allow/block lists). Every successful write fires config_reload on the daemon.",
1327+
},
1328+
subCommands: {
1329+
get: meetingConfigGetCmd,
1330+
set: meetingConfigSetCmd,
1331+
reset: meetingConfigResetCmd,
1332+
"allow-app": meetingConfigAllowAppCmd,
1333+
"unallow-app": meetingConfigUnallowAppCmd,
1334+
"block-app": meetingConfigBlockAppCmd,
1335+
"unblock-app": meetingConfigUnblockAppCmd,
1336+
"allow-url": meetingConfigAllowUrlCmd,
1337+
"unallow-url": meetingConfigUnallowUrlCmd,
1338+
"block-url": meetingConfigBlockUrlCmd,
1339+
"unblock-url": meetingConfigUnblockUrlCmd,
1340+
schedule: meetingConfigScheduleCmd,
1341+
export: meetingConfigExportCmd,
1342+
import: meetingConfigImportCmd,
1343+
},
1344+
});
1345+
10221346
const meetingCmd = defineCommand({
10231347
meta: { name: "meeting", description: "Meeting capture pipeline commands" },
10241348
subCommands: {
10251349
queue: queueGroupCmd,
10261350
status: meetingStatusCmd,
10271351
"permissions-check": meetingPermissionsCmd,
10281352
record: meetingRecordGroupCmd,
1353+
config: meetingConfigCmd,
10291354
watch: meetingWatchCmd,
10301355
menubar: meetingMenubarCmd,
10311356
"enable-watcher": meetingEnableWatcherCmd,

0 commit comments

Comments
 (0)