@frumu/tandem-panel 0.4.6 → 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"));
@@ -43,6 +47,21 @@ function loadDotEnvFile(pathname) {
43
47
  return true;
44
48
  }
45
49
 
50
+ function posixHomeForUser(username) {
51
+ const name = String(username || "").trim();
52
+ if (!name) return homedir();
53
+ try {
54
+ const passwd = readFileSync("/etc/passwd", "utf8");
55
+ for (const line of passwd.split(/\r?\n/)) {
56
+ if (!line || line.startsWith("#")) continue;
57
+ const parts = line.split(":");
58
+ if (parts[0] === name && parts[5]) return parts[5];
59
+ }
60
+ } catch {}
61
+ if (process.env.USER === name || process.env.SUDO_USER === name) return homedir();
62
+ return resolve("/home", name);
63
+ }
64
+
46
65
  function parseCliArgs(argv) {
47
66
  const flags = new Set();
48
67
  const values = new Map();
@@ -158,6 +177,9 @@ const ENGINE_PORT = Number.parseInt(process.env.TANDEM_ENGINE_PORT || "39731", 1
158
177
  const ENGINE_URL = (
159
178
  process.env.TANDEM_ENGINE_URL || `http://${ENGINE_HOST}:${ENGINE_PORT}`
160
179
  ).replace(/\/+$/, "");
180
+ const DEFAULT_TANDEM_SEARCH_URL = (
181
+ process.env.TANDEM_SEARCH_URL || "https://search.tandem.frumu.ai"
182
+ ).replace(/\/+$/, "");
161
183
  const SWARM_RUNS_PATH = resolve(homedir(), ".tandem", "control-panel", "swarm-runs.json");
162
184
  const SWARM_HIDDEN_RUNS_PATH = resolve(
163
185
  homedir(),
@@ -704,7 +726,8 @@ async function installServices() {
704
726
  const serviceGroup = serviceUser;
705
727
  const installEngine = serviceMode === "both" || serviceMode === "engine";
706
728
  const installPanel = serviceMode === "both" || serviceMode === "panel";
707
- const stateDir = String(process.env.TANDEM_STATE_DIR || "/srv/tandem").trim();
729
+ const defaultStateDir = resolve(posixHomeForUser(serviceUser), ".local", "share", "tandem");
730
+ const stateDir = String(process.env.TANDEM_HOME || process.env.TANDEM_STATE_DIR || defaultStateDir).trim();
708
731
  const engineEnvPath = "/etc/tandem/engine.env";
709
732
  const panelEnvPath = "/etc/tandem/control-panel.env";
710
733
  const engineServiceName = "tandem-engine";
@@ -728,11 +751,48 @@ async function installServices() {
728
751
  const existingEngineEnv = existsSync(engineEnvPath)
729
752
  ? parseDotEnv(readFileSync(engineEnvPath, "utf8"))
730
753
  : {};
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
+ : {};
731
791
  const engineEnv = {
732
- ...existingEngineEnv,
792
+ ...engineEnvBase,
733
793
  TANDEM_API_TOKEN: token,
734
794
  TANDEM_STATE_DIR: stateDir,
735
- TANDEM_MEMORY_DB_PATH: existingEngineEnv.TANDEM_MEMORY_DB_PATH || `${stateDir}/memory.sqlite`,
795
+ ...searchEnv,
736
796
  TANDEM_ENABLE_GLOBAL_MEMORY: existingEngineEnv.TANDEM_ENABLE_GLOBAL_MEMORY || "1",
737
797
  TANDEM_DISABLE_TOOL_GUARD_BUDGETS: existingEngineEnv.TANDEM_DISABLE_TOOL_GUARD_BUDGETS || "1",
738
798
  TANDEM_TOOL_ROUTER_ENABLED: existingEngineEnv.TANDEM_TOOL_ROUTER_ENABLED || "0",
@@ -951,6 +1011,127 @@ async function readJsonBody(req) {
951
1011
  return JSON.parse(raw);
952
1012
  }
953
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
+
954
1135
  function sendJson(res, code, payload) {
955
1136
  if (res.headersSent || res.writableEnded || res.destroyed) return;
956
1137
  const body = JSON.stringify(payload);
@@ -4353,6 +4534,29 @@ async function handleApi(req, res) {
4353
4534
  return true;
4354
4535
  }
4355
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
+
4356
4560
  if (pathname === "/api/auth/login" && req.method === "POST") {
4357
4561
  await handleAuthLogin(req, res);
4358
4562
  return true;