@frumu/tandem-panel 0.4.7 → 0.4.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env.example CHANGED
@@ -36,6 +36,17 @@ TANDEM_PROVIDER_STREAM_IDLE_TIMEOUT_MS=90000
36
36
  TANDEM_PERMISSION_WAIT_TIMEOUT_MS=15000
37
37
  TANDEM_TOOL_EXEC_TIMEOUT_MS=45000
38
38
  TANDEM_BASH_TIMEOUT_MS=30000
39
+
40
+ # Built-in websearch defaults. `auto` can try configured backends in order
41
+ # (managed Tandem URL, SearxNG, Brave, Exa) before falling back.
42
+ TANDEM_SEARCH_BACKEND=auto
43
+ TANDEM_SEARCH_URL=https://search.tandem.frumu.ai
44
+ TANDEM_SEARCH_TIMEOUT_MS=10000
45
+ # Optional direct-provider overrides
46
+ # TANDEM_BRAVE_SEARCH_API_KEY=
47
+ # TANDEM_EXA_API_KEY=
48
+ # TANDEM_SEARXNG_URL=http://127.0.0.1:8080
49
+
39
50
  # Bug Monitor (disabled by default)
40
51
  # TANDEM_BUG_MONITOR_ENABLED=0
41
52
  # TANDEM_BUG_MONITOR_REPO=owner/repo
package/README.md CHANGED
@@ -8,6 +8,16 @@ Full web control center for Tandem Engine (non-desktop entry point).
8
8
  npm i -g @frumu/tandem-panel
9
9
  ```
10
10
 
11
+ ## Editable App Scaffold
12
+
13
+ ```bash
14
+ npm create tandem-panel@latest my-panel
15
+ ```
16
+
17
+ Use the global install when you want the official ready-to-run panel.
18
+
19
+ Use the scaffold when you want the actual app source in your own folder so you can edit routes, pages, themes, styles, and runtime behavior without customizing files inside `node_modules`.
20
+
11
21
  ## Official Bootstrap
12
22
 
13
23
  ```bash
@@ -85,6 +95,15 @@ Variables:
85
95
  - `TANDEM_CONTROL_PANEL_ENGINE_TOKEN` (token injected when panel auto-starts engine)
86
96
  - `TANDEM_API_TOKEN` (backward-compatible alias for engine token)
87
97
  - `TANDEM_CONTROL_PANEL_SESSION_TTL_MINUTES` (default `1440`)
98
+ - `TANDEM_SEARCH_BACKEND` (`auto`, `tandem`, `brave`, `exa`, `searxng`, or `none`; default official installs use `auto`)
99
+ - `TANDEM_SEARCH_URL` (hosted Tandem search endpoint or compatible router URL)
100
+ - `TANDEM_SEARCH_TIMEOUT_MS` (default `10000`)
101
+ - `TANDEM_BRAVE_SEARCH_API_KEY` (optional direct Brave override when `TANDEM_SEARCH_BACKEND=brave`)
102
+ - `TANDEM_EXA_API_KEY` (optional direct Exa override when `TANDEM_SEARCH_BACKEND=exa`)
103
+ - `TANDEM_SEARXNG_URL` (optional self-hosted override when `TANDEM_SEARCH_BACKEND=searxng`)
104
+
105
+ The desktop app now exposes these search settings directly in Settings, and the control panel exposes them under Settings -> Web Search when it is connected to a local engine host.
106
+
88
107
  - `TANDEM_DISABLE_TOOL_GUARD_BUDGETS` (`1` disables per-run guard budgets; default in installer/service env is `1`)
89
108
  - `TANDEM_PROMPT_CONTEXT_HOOK_TIMEOUT_MS` (default `5000`)
90
109
  - `TANDEM_PROVIDER_STREAM_CONNECT_TIMEOUT_MS` (default `30000`)
@@ -144,17 +163,32 @@ npm run build
144
163
 
145
164
  ### Repo Source Workflow (No Global npm Install)
146
165
 
147
- If you run from this repo directly, use:
166
+ If you run from the repo root, use:
148
167
 
149
168
  ```bash
150
169
  node packages/tandem-control-panel/bin/cli.js init --no-service
151
170
  node packages/tandem-control-panel/bin/cli.js run
152
171
  ```
153
172
 
154
- Service install/ops from source:
173
+ If you are already inside `packages/tandem-control-panel`, use:
174
+
175
+ ```bash
176
+ node bin/cli.js init --no-service
177
+ node bin/cli.js run
178
+ ```
179
+
180
+ Service install/ops from source from the repo root:
155
181
 
156
182
  ```bash
157
183
  sudo node packages/tandem-control-panel/bin/cli.js service install
158
184
  node packages/tandem-control-panel/bin/cli.js service status
159
185
  sudo node packages/tandem-control-panel/bin/cli.js service restart
160
186
  ```
187
+
188
+ Service install/ops from inside `packages/tandem-control-panel`:
189
+
190
+ ```bash
191
+ sudo node bin/cli.js service install
192
+ node bin/cli.js service status
193
+ sudo node bin/cli.js service restart
194
+ ```
package/bin/setup.js CHANGED
@@ -34,6 +34,10 @@ function parseDotEnv(content) {
34
34
  return out;
35
35
  }
36
36
 
37
+ function serializeEnv(entries) {
38
+ return `${entries.map(([key, value]) => `${key}=${value}`).join("\n")}\n`;
39
+ }
40
+
37
41
  function loadDotEnvFile(pathname) {
38
42
  if (!existsSync(pathname)) return false;
39
43
  const parsed = parseDotEnv(readFileSync(pathname, "utf8"));
@@ -173,6 +177,9 @@ const ENGINE_PORT = Number.parseInt(process.env.TANDEM_ENGINE_PORT || "39731", 1
173
177
  const ENGINE_URL = (
174
178
  process.env.TANDEM_ENGINE_URL || `http://${ENGINE_HOST}:${ENGINE_PORT}`
175
179
  ).replace(/\/+$/, "");
180
+ const DEFAULT_TANDEM_SEARCH_URL = (
181
+ process.env.TANDEM_SEARCH_URL || "https://search.tandem.frumu.ai"
182
+ ).replace(/\/+$/, "");
176
183
  const SWARM_RUNS_PATH = resolve(homedir(), ".tandem", "control-panel", "swarm-runs.json");
177
184
  const SWARM_HIDDEN_RUNS_PATH = resolve(
178
185
  homedir(),
@@ -745,10 +752,47 @@ async function installServices() {
745
752
  ? parseDotEnv(readFileSync(engineEnvPath, "utf8"))
746
753
  : {};
747
754
  const { TANDEM_MEMORY_DB_PATH: _legacyMemoryDbPath, ...engineEnvBase } = existingEngineEnv;
755
+ const searchEnv =
756
+ existingEngineEnv.TANDEM_SEARCH_BACKEND ||
757
+ existingEngineEnv.TANDEM_SEARCH_URL ||
758
+ existingEngineEnv.TANDEM_SEARCH_TIMEOUT_MS ||
759
+ existingEngineEnv.TANDEM_BRAVE_SEARCH_API_KEY ||
760
+ existingEngineEnv.BRAVE_SEARCH_API_KEY ||
761
+ existingEngineEnv.TANDEM_EXA_API_KEY ||
762
+ existingEngineEnv.EXA_API_KEY ||
763
+ existingEngineEnv.TANDEM_SEARXNG_URL
764
+ ? {
765
+ ...(existingEngineEnv.TANDEM_SEARCH_BACKEND
766
+ ? { TANDEM_SEARCH_BACKEND: existingEngineEnv.TANDEM_SEARCH_BACKEND }
767
+ : {}),
768
+ ...(existingEngineEnv.TANDEM_SEARCH_URL
769
+ ? { TANDEM_SEARCH_URL: existingEngineEnv.TANDEM_SEARCH_URL }
770
+ : {}),
771
+ ...(existingEngineEnv.TANDEM_SEARCH_TIMEOUT_MS
772
+ ? { TANDEM_SEARCH_TIMEOUT_MS: existingEngineEnv.TANDEM_SEARCH_TIMEOUT_MS }
773
+ : {}),
774
+ ...(existingEngineEnv.TANDEM_BRAVE_SEARCH_API_KEY
775
+ ? { TANDEM_BRAVE_SEARCH_API_KEY: existingEngineEnv.TANDEM_BRAVE_SEARCH_API_KEY }
776
+ : {}),
777
+ ...(existingEngineEnv.BRAVE_SEARCH_API_KEY
778
+ ? { BRAVE_SEARCH_API_KEY: existingEngineEnv.BRAVE_SEARCH_API_KEY }
779
+ : {}),
780
+ ...(existingEngineEnv.TANDEM_EXA_API_KEY
781
+ ? { TANDEM_EXA_API_KEY: existingEngineEnv.TANDEM_EXA_API_KEY }
782
+ : {}),
783
+ ...(existingEngineEnv.EXA_API_KEY
784
+ ? { EXA_API_KEY: existingEngineEnv.EXA_API_KEY }
785
+ : {}),
786
+ ...(existingEngineEnv.TANDEM_SEARXNG_URL
787
+ ? { TANDEM_SEARXNG_URL: existingEngineEnv.TANDEM_SEARXNG_URL }
788
+ : {}),
789
+ }
790
+ : {};
748
791
  const engineEnv = {
749
792
  ...engineEnvBase,
750
793
  TANDEM_API_TOKEN: token,
751
794
  TANDEM_STATE_DIR: stateDir,
795
+ ...searchEnv,
752
796
  TANDEM_ENABLE_GLOBAL_MEMORY: existingEngineEnv.TANDEM_ENABLE_GLOBAL_MEMORY || "1",
753
797
  TANDEM_DISABLE_TOOL_GUARD_BUDGETS: existingEngineEnv.TANDEM_DISABLE_TOOL_GUARD_BUDGETS || "1",
754
798
  TANDEM_TOOL_ROUTER_ENABLED: existingEngineEnv.TANDEM_TOOL_ROUTER_ENABLED || "0",
@@ -967,6 +1011,127 @@ async function readJsonBody(req) {
967
1011
  return JSON.parse(raw);
968
1012
  }
969
1013
 
1014
+ function normalizeSearchBackend(raw) {
1015
+ switch (String(raw || "").trim().toLowerCase()) {
1016
+ case "":
1017
+ case "auto":
1018
+ return "auto";
1019
+ case "tandem":
1020
+ case "brave":
1021
+ case "exa":
1022
+ case "searxng":
1023
+ return String(raw).trim().toLowerCase();
1024
+ case "none":
1025
+ case "disabled":
1026
+ return "none";
1027
+ default:
1028
+ return "auto";
1029
+ }
1030
+ }
1031
+
1032
+ function normalizeSearchUrl(raw) {
1033
+ const value = String(raw || "").trim().replace(/\/+$/, "");
1034
+ return value || "";
1035
+ }
1036
+
1037
+ function getManagedEngineEnvPath() {
1038
+ return "/etc/tandem/engine.env";
1039
+ }
1040
+
1041
+ function readManagedSearchSettings() {
1042
+ const envPath = getManagedEngineEnvPath();
1043
+ const localEngine = isLocalEngineUrl(ENGINE_URL);
1044
+ const env = existsSync(envPath) ? parseDotEnv(readFileSync(envPath, "utf8")) : {};
1045
+ const timeoutRaw = Number.parseInt(String(env.TANDEM_SEARCH_TIMEOUT_MS || "10000"), 10);
1046
+ const timeoutMs = Number.isFinite(timeoutRaw)
1047
+ ? Math.min(Math.max(timeoutRaw, 1000), 120000)
1048
+ : 10000;
1049
+ return {
1050
+ available: localEngine,
1051
+ local_engine: localEngine,
1052
+ writable: localEngine,
1053
+ managed_env_path: envPath,
1054
+ restart_required: false,
1055
+ restart_hint: "Restart tandem-engine after saving search settings.",
1056
+ settings: {
1057
+ backend: normalizeSearchBackend(env.TANDEM_SEARCH_BACKEND || "auto"),
1058
+ tandem_url: normalizeSearchUrl(env.TANDEM_SEARCH_URL || ""),
1059
+ searxng_url: normalizeSearchUrl(env.TANDEM_SEARXNG_URL || ""),
1060
+ timeout_ms: timeoutMs,
1061
+ has_brave_key: !!String(
1062
+ env.TANDEM_BRAVE_SEARCH_API_KEY || env.BRAVE_SEARCH_API_KEY || ""
1063
+ ).trim(),
1064
+ has_exa_key: !!String(env.TANDEM_EXA_API_KEY || env.EXA_API_KEY || "").trim(),
1065
+ },
1066
+ reason: localEngine
1067
+ ? ""
1068
+ : "Search settings can only be edited here when the control panel points at a local engine host.",
1069
+ };
1070
+ }
1071
+
1072
+ async function writeManagedSearchSettings(payload = {}) {
1073
+ const current = readManagedSearchSettings();
1074
+ if (!current.local_engine) {
1075
+ const error = new Error(current.reason || "Search settings are not editable for this engine.");
1076
+ error.statusCode = 400;
1077
+ throw error;
1078
+ }
1079
+ const envPath = current.managed_env_path;
1080
+ const existingEnv = existsSync(envPath) ? parseDotEnv(readFileSync(envPath, "utf8")) : {};
1081
+ const nextEnv = { ...existingEnv };
1082
+
1083
+ nextEnv.TANDEM_SEARCH_BACKEND = normalizeSearchBackend(payload.backend || "auto");
1084
+ const timeoutRaw = Number.parseInt(String(payload.timeout_ms || payload.timeoutMs || "10000"), 10);
1085
+ nextEnv.TANDEM_SEARCH_TIMEOUT_MS = String(
1086
+ Number.isFinite(timeoutRaw) ? Math.min(Math.max(timeoutRaw, 1000), 120000) : 10000
1087
+ );
1088
+
1089
+ const tandemUrl = normalizeSearchUrl(payload.tandem_url || payload.tandemUrl || "");
1090
+ if (tandemUrl) nextEnv.TANDEM_SEARCH_URL = tandemUrl;
1091
+ else delete nextEnv.TANDEM_SEARCH_URL;
1092
+
1093
+ const searxngUrl = normalizeSearchUrl(payload.searxng_url || payload.searxngUrl || "");
1094
+ if (searxngUrl) nextEnv.TANDEM_SEARXNG_URL = searxngUrl;
1095
+ else delete nextEnv.TANDEM_SEARXNG_URL;
1096
+
1097
+ const braveKey = String(payload.brave_api_key || payload.braveApiKey || "").trim();
1098
+ if (braveKey) nextEnv.TANDEM_BRAVE_SEARCH_API_KEY = braveKey;
1099
+ else if (payload.clear_brave_key || payload.clearBraveKey) {
1100
+ delete nextEnv.TANDEM_BRAVE_SEARCH_API_KEY;
1101
+ delete nextEnv.BRAVE_SEARCH_API_KEY;
1102
+ }
1103
+
1104
+ const exaKey = String(payload.exa_api_key || payload.exaApiKey || "").trim();
1105
+ if (exaKey) nextEnv.TANDEM_EXA_API_KEY = exaKey;
1106
+ else if (payload.clear_exa_key || payload.clearExaKey) {
1107
+ delete nextEnv.TANDEM_EXA_API_KEY;
1108
+ delete nextEnv.EXA_API_KEY;
1109
+ }
1110
+
1111
+ const preferredKeys = [
1112
+ "TANDEM_API_TOKEN",
1113
+ "TANDEM_STATE_DIR",
1114
+ "TANDEM_SEARCH_BACKEND",
1115
+ "TANDEM_SEARCH_URL",
1116
+ "TANDEM_SEARXNG_URL",
1117
+ "TANDEM_SEARCH_TIMEOUT_MS",
1118
+ "TANDEM_BRAVE_SEARCH_API_KEY",
1119
+ "TANDEM_EXA_API_KEY",
1120
+ ];
1121
+ const ordered = [];
1122
+ for (const key of preferredKeys) {
1123
+ if (nextEnv[key] !== undefined) ordered.push([key, nextEnv[key]]);
1124
+ }
1125
+ for (const [key, value] of Object.entries(nextEnv)) {
1126
+ if (!preferredKeys.includes(key)) ordered.push([key, value]);
1127
+ }
1128
+ await writeFile(envPath, serializeEnv(ordered), "utf8");
1129
+ return {
1130
+ ...readManagedSearchSettings(),
1131
+ restart_required: true,
1132
+ };
1133
+ }
1134
+
970
1135
  function sendJson(res, code, payload) {
971
1136
  if (res.headersSent || res.writableEnded || res.destroyed) return;
972
1137
  const body = JSON.stringify(payload);
@@ -4369,6 +4534,29 @@ async function handleApi(req, res) {
4369
4534
  return true;
4370
4535
  }
4371
4536
 
4537
+ if (pathname === "/api/system/search-settings" && req.method === "GET") {
4538
+ const session = requireSession(req, res);
4539
+ if (!session) return true;
4540
+ sendJson(res, 200, readManagedSearchSettings());
4541
+ return true;
4542
+ }
4543
+
4544
+ if (pathname === "/api/system/search-settings" && req.method === "PATCH") {
4545
+ const session = requireSession(req, res);
4546
+ if (!session) return true;
4547
+ try {
4548
+ const payload = await readJsonBody(req);
4549
+ const saved = await writeManagedSearchSettings(payload);
4550
+ sendJson(res, 200, saved);
4551
+ } catch (error) {
4552
+ sendJson(res, Number(error?.statusCode || 500), {
4553
+ ok: false,
4554
+ error: error instanceof Error ? error.message : String(error),
4555
+ });
4556
+ }
4557
+ return true;
4558
+ }
4559
+
4372
4560
  if (pathname === "/api/auth/login" && req.method === "POST") {
4373
4561
  await handleAuthLogin(req, res);
4374
4562
  return true;