@rubytech/taskmaster 1.0.66 → 1.0.68

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.
@@ -6,8 +6,8 @@
6
6
  <title>Taskmaster Control</title>
7
7
  <meta name="color-scheme" content="dark light" />
8
8
  <link rel="icon" type="image/png" href="./favicon.png" />
9
- <script type="module" crossorigin src="./assets/index-8pJBjxcK.js"></script>
10
- <link rel="stylesheet" crossorigin href="./assets/index-Uo_tQYx1.css">
9
+ <script type="module" crossorigin src="./assets/index-Tpr1NFEw.js"></script>
10
+ <link rel="stylesheet" crossorigin href="./assets/index-BCh3mx9Z.css">
11
11
  </head>
12
12
  <body>
13
13
  <taskmaster-app></taskmaster-app>
@@ -1,22 +1,29 @@
1
1
  import net from "node:net";
2
- import os from "node:os";
3
2
  import { pickPrimaryTailnetIPv4, pickPrimaryTailnetIPv6 } from "../infra/tailnet.js";
4
3
  /**
5
- * Collect all IP addresses assigned to this machine's network interfaces.
6
- * Includes LAN, loopback, Tailnet, and any other bound address.
7
- * Used to distinguish same-machine access from truly external requests.
4
+ * Check if an IPv4 address belongs to a private (RFC 1918) or link-local range.
5
+ * These addresses are non-routable on the public internet, so any request
6
+ * from one is by definition on the local network — safe for LAN access.
7
+ *
8
+ * Ranges: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16
8
9
  */
9
- function getOwnIpAddresses() {
10
- const addresses = new Set();
11
- const interfaces = os.networkInterfaces();
12
- for (const iface of Object.values(interfaces)) {
13
- if (!iface)
14
- continue;
15
- for (const info of iface) {
16
- addresses.add(info.address.toLowerCase());
17
- }
18
- }
19
- return addresses;
10
+ function isPrivateIPv4(ip) {
11
+ const parts = ip.split(".");
12
+ if (parts.length !== 4)
13
+ return false;
14
+ const a = parseInt(parts[0], 10);
15
+ const b = parseInt(parts[1], 10);
16
+ if (Number.isNaN(a) || Number.isNaN(b))
17
+ return false;
18
+ if (a === 10)
19
+ return true; // 10.0.0.0/8
20
+ if (a === 172 && b >= 16 && b <= 31)
21
+ return true; // 172.16.0.0/12
22
+ if (a === 192 && b === 168)
23
+ return true; // 192.168.0.0/16
24
+ if (a === 169 && b === 254)
25
+ return true; // 169.254.0.0/16 (link-local)
26
+ return false;
20
27
  }
21
28
  export function isLoopbackAddress(ip) {
22
29
  if (!ip)
@@ -96,9 +103,9 @@ export function isLocalGatewayAddress(ip) {
96
103
  const tailnetIPv6 = pickPrimaryTailnetIPv6();
97
104
  if (tailnetIPv6 && ip.trim().toLowerCase() === tailnetIPv6.toLowerCase())
98
105
  return true;
99
- // Check all IPs assigned to this machine's network interfaces.
100
- // Covers same-machine access via LAN hostname, mDNS (.local), etc.
101
- if (getOwnIpAddresses().has(normalized))
106
+ // Any RFC 1918 private or link-local address is on the local network.
107
+ // These are non-routable on the public internet, so they're safe.
108
+ if (isPrivateIPv4(normalized))
102
109
  return true;
103
110
  return false;
104
111
  }
@@ -5,35 +5,27 @@
5
5
  * Anonymous sessions use a cookie-based identifier; verified sessions use the
6
6
  * phone number so they share the same DM session as WhatsApp.
7
7
  */
8
- import { resolveDefaultAgentId } from "../../agents/agent-scope.js";
8
+ import { listBoundAccountIds } from "../../routing/bindings.js";
9
+ import { resolveAgentRoute } from "../../routing/resolve-route.js";
9
10
  import { normalizeAgentId } from "../../routing/session-key.js";
10
11
  /**
11
12
  * Find the agent that handles public-facing WhatsApp DMs.
12
- * Priority: binding to whatsapp DM > agent named "public" > default agent.
13
+ *
14
+ * Uses the same routing logic as WhatsApp itself: calls resolveAgentRoute
15
+ * with a synthetic unknown-peer DM on the first WhatsApp account. This
16
+ * guarantees the public chat routes to the exact same agent that handles
17
+ * unknown WhatsApp DMs.
13
18
  */
14
19
  export function resolvePublicAgentId(cfg) {
15
- const bindings = cfg.bindings ?? [];
16
- // Find agent bound to whatsapp DMs (the public-facing agent)
17
- for (const binding of bindings) {
18
- if (binding.match.channel === "whatsapp" &&
19
- binding.match.peer?.kind === "dm" &&
20
- !binding.match.peer.id) {
21
- return normalizeAgentId(binding.agentId);
22
- }
23
- }
24
- // Any whatsapp binding
25
- for (const binding of bindings) {
26
- if (binding.match.channel === "whatsapp") {
27
- return normalizeAgentId(binding.agentId);
28
- }
29
- }
30
- // Agent explicitly named "public"
31
- const agents = cfg.agents?.list ?? [];
32
- const publicAgent = agents.find((a) => a.id === "public");
33
- if (publicAgent)
34
- return normalizeAgentId(publicAgent.id);
35
- // Fall back to default agent
36
- return resolveDefaultAgentId(cfg);
20
+ const accountIds = listBoundAccountIds(cfg, "whatsapp");
21
+ const accountId = accountIds[0] ?? "default";
22
+ const route = resolveAgentRoute({
23
+ cfg,
24
+ channel: "whatsapp",
25
+ accountId,
26
+ peer: { kind: "dm", id: "__public_chat__" },
27
+ });
28
+ return normalizeAgentId(route.agentId);
37
29
  }
38
30
  /**
39
31
  * Build the session key for a public-chat visitor.
@@ -20,6 +20,8 @@ export const memoryHandlers = {
20
20
  ok: true,
21
21
  agentId,
22
22
  dirty: status.dirty,
23
+ syncing: status.syncing,
24
+ syncProgress: status.syncProgress,
23
25
  files: status.files,
24
26
  chunks: status.chunks,
25
27
  sources: status.sources,
@@ -41,6 +41,14 @@ function normalizeGeminiBaseUrl(raw) {
41
41
  function buildGeminiModelPath(model) {
42
42
  return model.startsWith("models/") ? model : `models/${model}`;
43
43
  }
44
+ /** Extract retry delay from a Gemini 429 response body, defaulting to 60s. */
45
+ function parseRetryDelay(body) {
46
+ // Gemini includes "retryDelay": "52s" in the response.
47
+ const match = body.match(/"retryDelay"\s*:\s*"(\d+)s?"/);
48
+ if (match)
49
+ return Math.max(1, Number(match[1]));
50
+ return 60;
51
+ }
44
52
  export async function createGeminiEmbeddingProvider(options) {
45
53
  const client = await resolveGeminiEmbeddingClient(options);
46
54
  const baseUrl = client.baseUrl.replace(/\/$/, "");
@@ -49,20 +57,31 @@ export async function createGeminiEmbeddingProvider(options) {
49
57
  const embedQuery = async (text) => {
50
58
  if (!text.trim())
51
59
  return [];
52
- const res = await fetch(embedUrl, {
53
- method: "POST",
54
- headers: client.headers,
55
- body: JSON.stringify({
56
- content: { parts: [{ text }] },
57
- taskType: "RETRIEVAL_QUERY",
58
- }),
60
+ const body = JSON.stringify({
61
+ content: { parts: [{ text }] },
62
+ taskType: "RETRIEVAL_QUERY",
59
63
  });
60
- if (!res.ok) {
61
- const payload = await res.text();
62
- throw new Error(`gemini embeddings failed: ${res.status} ${payload}`);
64
+ const maxRetries = 3;
65
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
66
+ const res = await fetch(embedUrl, {
67
+ method: "POST",
68
+ headers: client.headers,
69
+ body,
70
+ });
71
+ if (res.status === 429 && attempt < maxRetries) {
72
+ const retryAfter = parseRetryDelay(await res.text());
73
+ log.info(`gemini rate limit hit; retrying in ${retryAfter}s`);
74
+ await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000));
75
+ continue;
76
+ }
77
+ if (!res.ok) {
78
+ const payload = await res.text();
79
+ throw new Error(`gemini embeddings failed: ${res.status} ${payload}`);
80
+ }
81
+ const payload = (await res.json());
82
+ return payload.embedding?.values ?? [];
63
83
  }
64
- const payload = (await res.json());
65
- return payload.embedding?.values ?? [];
84
+ throw new Error("gemini embeddings: exhausted retries after rate limiting");
66
85
  };
67
86
  const embedBatch = async (texts) => {
68
87
  if (texts.length === 0)
@@ -72,18 +91,31 @@ export async function createGeminiEmbeddingProvider(options) {
72
91
  content: { parts: [{ text }] },
73
92
  taskType: "RETRIEVAL_DOCUMENT",
74
93
  }));
75
- const res = await fetch(batchUrl, {
76
- method: "POST",
77
- headers: client.headers,
78
- body: JSON.stringify({ requests }),
79
- });
80
- if (!res.ok) {
81
- const payload = await res.text();
82
- throw new Error(`gemini embeddings failed: ${res.status} ${payload}`);
94
+ const body = JSON.stringify({ requests });
95
+ // Retry on 429 (rate limit) — Gemini free tier caps at 100 requests/minute.
96
+ // The initial bulk index can exceed this; retrying after the cooldown lets it complete.
97
+ const maxRetries = 3;
98
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
99
+ const res = await fetch(batchUrl, {
100
+ method: "POST",
101
+ headers: client.headers,
102
+ body,
103
+ });
104
+ if (res.status === 429 && attempt < maxRetries) {
105
+ const retryAfter = parseRetryDelay(await res.text());
106
+ log.info(`gemini rate limit hit; retrying in ${retryAfter}s`);
107
+ await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000));
108
+ continue;
109
+ }
110
+ if (!res.ok) {
111
+ const payload = await res.text();
112
+ throw new Error(`gemini embeddings failed: ${res.status} ${payload}`);
113
+ }
114
+ const payload = (await res.json());
115
+ const embeddings = Array.isArray(payload.embeddings) ? payload.embeddings : [];
116
+ return texts.map((_, index) => embeddings[index]?.values ?? []);
83
117
  }
84
- const payload = (await res.json());
85
- const embeddings = Array.isArray(payload.embeddings) ? payload.embeddings : [];
86
- return texts.map((_, index) => embeddings[index]?.values ?? []);
118
+ throw new Error("gemini embeddings: exhausted retries after rate limiting");
87
119
  };
88
120
  return {
89
121
  provider: {
@@ -6,16 +6,15 @@ import { createGeminiEmbeddingProvider } from "./embeddings-gemini.js";
6
6
  import { createOpenAiEmbeddingProvider } from "./embeddings-openai.js";
7
7
  import { importNodeLlamaCpp } from "./node-llama.js";
8
8
  /**
9
- * Default local embedding model. The 0.6B model is small enough to run on
10
- * any target hardware (Pi 4GB, Pi 8GB, Mac) without GPU and with minimal
11
- * RAM overhead (~1-2 GB runtime). Larger models (4B, 8B) can be configured
12
- * explicitly via `local.modelPath` for users who have tested them on their
13
- * specific hardware node-llama-cpp's actual memory footprint varies
14
- * enormously across GPU vendors and Metal/Vulkan backends.
9
+ * Default local embedding model. embeddinggemma (329 MB) is proven stable on
10
+ * Intel x64 Macs and ARM Pis via node-llama-cpp's CPU backend. Larger models
11
+ * (Qwen3-Embedding 0.6B/4B/8B) can be configured explicitly via
12
+ * `local.modelPath` but are untested across hardware Qwen3 models caused
13
+ * runaway memory consumption (40-76 GB) on Intel x64 Mac + AMD GPU.
15
14
  */
16
15
  const DEFAULT_LOCAL_MODEL = {
17
- model: "hf:Qwen/Qwen3-Embedding-0.6B-GGUF/Qwen3-Embedding-0.6B-Q8_0.gguf",
18
- label: "Qwen3-Embedding-0.6B",
16
+ model: "hf:ggml-org/embeddinggemma-300M-Q8_0-GGUF/embeddinggemma-300M-Q8_0.gguf",
17
+ label: "embeddinggemma-300M",
19
18
  };
20
19
  function selectDefaultLocalModel() {
21
20
  return DEFAULT_LOCAL_MODEL;
@@ -174,36 +173,49 @@ export async function createEmbeddingProvider(options) {
174
173
  };
175
174
  const formatPrimaryError = (err, provider) => provider === "local" ? formatLocalSetupError(err) : formatError(err);
176
175
  if (requestedProvider === "auto") {
177
- const missingKeyErrors = [];
178
- let localError = null;
176
+ const errors = [];
177
+ // 1. If user configured an explicit local model file, try it first.
179
178
  if (canAutoSelectLocal(options)) {
180
179
  try {
181
180
  const local = await createProvider("local");
182
181
  return { ...local, requestedProvider };
183
182
  }
184
183
  catch (err) {
185
- localError = formatLocalSetupError(err);
184
+ errors.push(formatLocalSetupError(err));
186
185
  }
187
186
  }
188
- for (const provider of ["openai", "gemini"]) {
187
+ // 2. Try remote providers preferred when API keys are available
188
+ // (faster, no RAM overhead, no model download).
189
+ // Only consider providers whose key is in config.apiKeys.
190
+ // Environment variables are ignored for auto-selection to avoid
191
+ // stale dev keys that would fail at runtime.
192
+ const configKeys = options.config.apiKeys ?? {};
193
+ const remoteProviders = [
194
+ { id: "openai", configKey: "openai" },
195
+ { id: "gemini", configKey: "google" },
196
+ ];
197
+ for (const { id, configKey } of remoteProviders) {
198
+ if (!configKeys[configKey]?.trim())
199
+ continue;
189
200
  try {
190
- const result = await createProvider(provider);
201
+ const result = await createProvider(id);
191
202
  return { ...result, requestedProvider };
192
203
  }
193
204
  catch (err) {
194
- const message = formatPrimaryError(err, provider);
195
- if (isMissingApiKeyError(err)) {
196
- missingKeyErrors.push(message);
197
- continue;
198
- }
199
- throw new Error(message);
205
+ errors.push(formatPrimaryError(err, id));
200
206
  }
201
207
  }
202
- const details = [...missingKeyErrors, localError].filter(Boolean);
203
- if (details.length > 0) {
204
- throw new Error(details.join("\n\n"));
208
+ // 3. Fall back to local (downloads default model on first use).
209
+ try {
210
+ const local = await createProvider("local");
211
+ return { ...local, requestedProvider };
205
212
  }
206
- throw new Error("No embeddings provider available.");
213
+ catch (err) {
214
+ errors.push(formatLocalSetupError(err));
215
+ }
216
+ throw new Error(errors.length > 0
217
+ ? errors.join("\n\n")
218
+ : "No embeddings provider available.");
207
219
  }
208
220
  try {
209
221
  const primary = await createProvider(requestedProvider);
@@ -282,6 +282,7 @@ export class MemoryIndexManager {
282
282
  sessionDeltas = new Map();
283
283
  sessionWarm = new Set();
284
284
  syncing = null;
285
+ syncProgress = null;
285
286
  /**
286
287
  * Ensure standard memory directory structure exists.
287
288
  * Creates: memory/public, memory/shared, memory/admin, memory/users
@@ -509,8 +510,18 @@ export class MemoryIndexManager {
509
510
  async sync(params) {
510
511
  if (this.syncing)
511
512
  return this.syncing;
512
- this.syncing = this.runSync(params).finally(() => {
513
+ this.syncProgress = { completed: 0, total: 0 };
514
+ const outerProgress = params?.progress;
515
+ const wrappedParams = {
516
+ ...params,
517
+ progress: (update) => {
518
+ this.syncProgress = { completed: update.completed, total: update.total };
519
+ outerProgress?.(update);
520
+ },
521
+ };
522
+ this.syncing = this.runSync(wrappedParams).finally(() => {
513
523
  this.syncing = null;
524
+ this.syncProgress = null;
514
525
  });
515
526
  return this.syncing;
516
527
  }
@@ -663,6 +674,8 @@ export class MemoryIndexManager {
663
674
  files: files?.c ?? 0,
664
675
  chunks: chunks?.c ?? 0,
665
676
  dirty: this.dirty,
677
+ syncing: this.syncing !== null,
678
+ syncProgress: this.syncProgress ?? undefined,
666
679
  workspaceDir: this.workspaceDir,
667
680
  dbPath: this.settings.store.path,
668
681
  provider: this.provider.id,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/taskmaster",
3
- "version": "1.0.66",
3
+ "version": "1.0.68",
4
4
  "description": "AI-powered business assistant for small businesses",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -1267,6 +1267,57 @@ If the page loses connection during the update and doesn't reconnect within two
1267
1267
 
1268
1268
  ---
1269
1269
 
1270
+ ## Command Line (CLI)
1271
+
1272
+ Taskmaster includes a command-line tool called `taskmaster` that you can run in a terminal (Terminal on Mac, or via SSH on a Pi). You don't need the command line for day-to-day use — the Control Panel handles everything — but it's useful for troubleshooting, updating when the Control Panel isn't accessible, or automating tasks.
1273
+
1274
+ ### How to access the terminal
1275
+
1276
+ - **Mac:** Open **Terminal** (search for "Terminal" in Spotlight)
1277
+ - **Raspberry Pi (direct):** Plug in a keyboard and monitor, open Terminal from the taskbar
1278
+ - **Raspberry Pi (remote):** From another computer, run `ssh admin@taskmaster.local` (replace `taskmaster` with your hostname if changed)
1279
+
1280
+ ### Essential commands
1281
+
1282
+ | Command | What it does |
1283
+ |---------|-------------|
1284
+ | `taskmaster update` | Update to the latest version and restart the service |
1285
+ | `taskmaster daemon restart` | Restart the gateway service (picks up code and config changes) |
1286
+ | `taskmaster daemon status` | Check if the gateway service is running |
1287
+ | `taskmaster doctor` | Run health checks and fix common issues automatically |
1288
+ | `taskmaster status` | Show connection status for all channels (WhatsApp, Claude, etc.) |
1289
+ | `taskmaster dashboard` | Open the Control Panel in your browser |
1290
+
1291
+ ### Configuration commands
1292
+
1293
+ | Command | What it does |
1294
+ |---------|-------------|
1295
+ | `taskmaster config set <key> <value>` | Change a config setting (e.g., `taskmaster config set gateway.port 19000`) |
1296
+ | `taskmaster config get <key>` | Read a config setting |
1297
+ | `taskmaster config` | Open the interactive config wizard |
1298
+
1299
+ ### When to use the CLI instead of the Control Panel
1300
+
1301
+ - **Can't reach the Control Panel** — If the web UI is down, SSH into the device and use CLI commands to restart or update
1302
+ - **Updating from a specific version** — `taskmaster update` works even when the Control Panel can't load
1303
+ - **Changing network settings** — Port and hostname changes require the terminal (see "Changing Network Settings" above)
1304
+ - **Diagnosing problems** — `taskmaster doctor` runs automated health checks that can detect and fix issues the UI can't show
1305
+ - **Scripting and automation** — CLI commands can be combined in scripts for advanced setups
1306
+
1307
+ ### Getting help for any command
1308
+
1309
+ Add `--help` to any command to see its options:
1310
+
1311
+ ```bash
1312
+ taskmaster update --help
1313
+ taskmaster daemon --help
1314
+ taskmaster config set --help
1315
+ ```
1316
+
1317
+ Or run `taskmaster help` to see all available commands.
1318
+
1319
+ ---
1320
+
1270
1321
  ## Uninstalling Taskmaster
1271
1322
 
1272
1323
  ### From the Control Panel