@rubytech/taskmaster 1.0.65 → 1.0.67

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.
@@ -1,5 +1,30 @@
1
1
  import net from "node:net";
2
2
  import { pickPrimaryTailnetIPv4, pickPrimaryTailnetIPv6 } from "../infra/tailnet.js";
3
+ /**
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
9
+ */
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;
27
+ }
3
28
  export function isLoopbackAddress(ip) {
4
29
  if (!ip)
5
30
  return false;
@@ -78,6 +103,10 @@ export function isLocalGatewayAddress(ip) {
78
103
  const tailnetIPv6 = pickPrimaryTailnetIPv6();
79
104
  if (tailnetIPv6 && ip.trim().toLowerCase() === tailnetIPv6.toLowerCase())
80
105
  return true;
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))
109
+ return true;
81
110
  return false;
82
111
  }
83
112
  /**
@@ -27,6 +27,7 @@ export const memoryHandlers = {
27
27
  provider: status.provider,
28
28
  model: status.model,
29
29
  fts: status.fts,
30
+ embeddingState: status.embeddingState,
30
31
  });
31
32
  }
32
33
  catch (err) {
@@ -1,9 +1,26 @@
1
1
  import fsSync from "node:fs";
2
+ import os from "node:os";
3
+ import { createSubsystemLogger } from "../logging/subsystem.js";
2
4
  import { resolveUserPath } from "../utils.js";
5
+ import { getCustomProviderApiKey } from "../agents/model-auth.js";
3
6
  import { createGeminiEmbeddingProvider } from "./embeddings-gemini.js";
4
7
  import { createOpenAiEmbeddingProvider } from "./embeddings-openai.js";
5
8
  import { importNodeLlamaCpp } from "./node-llama.js";
6
- const DEFAULT_LOCAL_MODEL = "hf:ggml-org/embeddinggemma-300M-GGUF/embeddinggemma-300M-Q8_0.gguf";
9
+ /**
10
+ * Default local embedding model. The 0.6B model is small enough to run on
11
+ * any target hardware (Pi 4GB, Pi 8GB, Mac) without GPU and with minimal
12
+ * RAM overhead (~1-2 GB runtime). Larger models (4B, 8B) can be configured
13
+ * explicitly via `local.modelPath` for users who have tested them on their
14
+ * specific hardware — node-llama-cpp's actual memory footprint varies
15
+ * enormously across GPU vendors and Metal/Vulkan backends.
16
+ */
17
+ const DEFAULT_LOCAL_MODEL = {
18
+ model: "hf:Qwen/Qwen3-Embedding-0.6B-GGUF/Qwen3-Embedding-0.6B-Q8_0.gguf",
19
+ label: "Qwen3-Embedding-0.6B",
20
+ };
21
+ function selectDefaultLocalModel() {
22
+ return DEFAULT_LOCAL_MODEL;
23
+ }
7
24
  function canAutoSelectLocal(options) {
8
25
  const modelPath = options.local?.modelPath?.trim();
9
26
  if (!modelPath)
@@ -22,30 +39,85 @@ function isMissingApiKeyError(err) {
22
39
  const message = formatError(err);
23
40
  return message.includes("No API key found for provider");
24
41
  }
42
+ /**
43
+ * Deduplicates concurrent model downloads. When multiple agents resolve the
44
+ * same model path, only one download runs; the rest await the same promise.
45
+ */
46
+ const inflightDownloads = new Map();
47
+ /**
48
+ * Cache of resolved model paths (model URI → absolute file path on disk).
49
+ * Persists across restarts via a JSON file so we skip `resolveModelFile`
50
+ * (which re-downloads even when the file exists due to a filename mismatch
51
+ * between the expected URI-derived name and the actual hf-prefixed name).
52
+ */
53
+ const RESOLVED_PATHS_FILE = (() => {
54
+ const homeDir = os.homedir();
55
+ return `${homeDir}/.taskmaster/memory/resolved-model-paths.json`;
56
+ })();
57
+ function readResolvedPaths() {
58
+ try {
59
+ return JSON.parse(fsSync.readFileSync(RESOLVED_PATHS_FILE, "utf8"));
60
+ }
61
+ catch {
62
+ return {};
63
+ }
64
+ }
65
+ function writeResolvedPath(key, resolvedPath) {
66
+ try {
67
+ const dir = RESOLVED_PATHS_FILE.replace(/\/[^/]+$/, "");
68
+ fsSync.mkdirSync(dir, { recursive: true });
69
+ const existing = readResolvedPaths();
70
+ existing[key] = resolvedPath;
71
+ fsSync.writeFileSync(RESOLVED_PATHS_FILE, JSON.stringify(existing, null, 2));
72
+ }
73
+ catch {
74
+ // Non-fatal — next restart will re-resolve
75
+ }
76
+ }
77
+ async function resolveModelFileOnce(resolveModelFile, modelPath, modelCacheDir) {
78
+ const key = `${modelPath}::${modelCacheDir ?? ""}`;
79
+ // Check on-disk cache first — avoids re-download on restart
80
+ const cached = readResolvedPaths()[key];
81
+ if (cached && fsSync.existsSync(cached)) {
82
+ return cached;
83
+ }
84
+ // Deduplicate concurrent calls
85
+ const existing = inflightDownloads.get(key);
86
+ if (existing)
87
+ return existing;
88
+ const promise = resolveModelFile(modelPath, modelCacheDir || undefined)
89
+ .then((resolved) => {
90
+ writeResolvedPath(key, resolved);
91
+ return resolved;
92
+ })
93
+ .finally(() => {
94
+ inflightDownloads.delete(key);
95
+ });
96
+ inflightDownloads.set(key, promise);
97
+ return promise;
98
+ }
25
99
  async function createLocalEmbeddingProvider(options) {
26
- const modelPath = options.local?.modelPath?.trim() || DEFAULT_LOCAL_MODEL;
100
+ const log = createSubsystemLogger("embeddings");
101
+ const hasExplicitModelPath = Boolean(options.local?.modelPath?.trim());
102
+ let modelPath;
103
+ if (hasExplicitModelPath) {
104
+ modelPath = options.local.modelPath.trim();
105
+ }
106
+ else {
107
+ const selected = selectDefaultLocalModel();
108
+ modelPath = selected.model;
109
+ log.info(`selected tier ${selected.label} (system RAM: ${(os.totalmem() / (1024 ** 3)).toFixed(1)} GB)`);
110
+ }
27
111
  const modelCacheDir = options.local?.modelCacheDir?.trim();
28
112
  // Lazy-load node-llama-cpp to keep startup light unless local is enabled.
29
113
  const { getLlama, resolveModelFile, LlamaLogLevel } = await importNodeLlamaCpp();
30
114
  let llama = null;
31
115
  let embeddingModel = null;
32
116
  let embeddingContext = null;
33
- const ensureContext = async () => {
34
- if (!llama) {
35
- llama = await getLlama({ logLevel: LlamaLogLevel.error });
36
- }
37
- if (!embeddingModel) {
38
- const resolved = await resolveModelFile(modelPath, modelCacheDir || undefined);
39
- embeddingModel = await llama.loadModel({ modelPath: resolved });
40
- }
41
- if (!embeddingContext) {
42
- embeddingContext = await embeddingModel.createEmbeddingContext();
43
- }
44
- return embeddingContext;
45
- };
46
- return {
117
+ const provider = {
47
118
  id: "local",
48
119
  model: modelPath,
120
+ state: "idle",
49
121
  embedQuery: async (text) => {
50
122
  const ctx = await ensureContext();
51
123
  const embedding = await ctx.getEmbeddingFor(text);
@@ -60,6 +132,31 @@ async function createLocalEmbeddingProvider(options) {
60
132
  return embeddings;
61
133
  },
62
134
  };
135
+ const ensureContext = async () => {
136
+ if (!llama) {
137
+ // Force CPU-only: GPU backends (Metal on AMD, Vulkan) allocate enormous
138
+ // memory buffers that dwarf the model itself and can OOM the host.
139
+ // Embedding models are small enough that CPU inference is fast and safe.
140
+ llama = await getLlama({ gpu: false, logLevel: LlamaLogLevel.error });
141
+ }
142
+ if (!embeddingModel) {
143
+ try {
144
+ provider.state = "downloading";
145
+ const resolved = await resolveModelFileOnce(resolveModelFile, modelPath, modelCacheDir);
146
+ embeddingModel = await llama.loadModel({ modelPath: resolved, gpuLayers: 0 });
147
+ }
148
+ catch (err) {
149
+ provider.state = "error";
150
+ throw err;
151
+ }
152
+ }
153
+ if (!embeddingContext) {
154
+ embeddingContext = await embeddingModel.createEmbeddingContext();
155
+ }
156
+ provider.state = "ready";
157
+ return embeddingContext;
158
+ };
159
+ return provider;
63
160
  }
64
161
  export async function createEmbeddingProvider(options) {
65
162
  const requestedProvider = options.provider;
@@ -78,36 +175,48 @@ export async function createEmbeddingProvider(options) {
78
175
  };
79
176
  const formatPrimaryError = (err, provider) => provider === "local" ? formatLocalSetupError(err) : formatError(err);
80
177
  if (requestedProvider === "auto") {
81
- const missingKeyErrors = [];
82
- let localError = null;
178
+ const errors = [];
179
+ // 1. If user configured an explicit local model file, try it first.
83
180
  if (canAutoSelectLocal(options)) {
84
181
  try {
85
182
  const local = await createProvider("local");
86
183
  return { ...local, requestedProvider };
87
184
  }
88
185
  catch (err) {
89
- localError = formatLocalSetupError(err);
186
+ errors.push(formatLocalSetupError(err));
90
187
  }
91
188
  }
92
- for (const provider of ["openai", "gemini"]) {
189
+ // 2. Try remote providers preferred when API keys are available
190
+ // (faster, no RAM overhead, no model download).
191
+ // Only consider providers whose key is in the config (apiKeys section).
192
+ // Environment variables are ignored for auto-selection to avoid using
193
+ // stale dev keys that would fail at runtime.
194
+ const remoteProviders = [
195
+ { id: "openai", configKey: "openai" },
196
+ { id: "gemini", configKey: "google" },
197
+ ];
198
+ for (const { id, configKey } of remoteProviders) {
199
+ if (!getCustomProviderApiKey(options.config, configKey))
200
+ continue;
93
201
  try {
94
- const result = await createProvider(provider);
202
+ const result = await createProvider(id);
95
203
  return { ...result, requestedProvider };
96
204
  }
97
205
  catch (err) {
98
- const message = formatPrimaryError(err, provider);
99
- if (isMissingApiKeyError(err)) {
100
- missingKeyErrors.push(message);
101
- continue;
102
- }
103
- throw new Error(message);
206
+ errors.push(formatPrimaryError(err, id));
104
207
  }
105
208
  }
106
- const details = [...missingKeyErrors, localError].filter(Boolean);
107
- if (details.length > 0) {
108
- throw new Error(details.join("\n\n"));
209
+ // 3. Fall back to local (downloads default model on first use).
210
+ try {
211
+ const local = await createProvider("local");
212
+ return { ...local, requestedProvider };
213
+ }
214
+ catch (err) {
215
+ errors.push(formatLocalSetupError(err));
109
216
  }
110
- throw new Error("No embeddings provider available.");
217
+ throw new Error(errors.length > 0
218
+ ? errors.join("\n\n")
219
+ : "No embeddings provider available.");
111
220
  }
112
221
  try {
113
222
  const primary = await createProvider(requestedProvider);
@@ -670,6 +670,7 @@ export class MemoryIndexManager {
670
670
  requestedProvider: this.requestedProvider,
671
671
  sources: Array.from(this.sources),
672
672
  sourceCounts,
673
+ embeddingState: this.provider.state,
673
674
  cache: this.cache.enabled
674
675
  ? {
675
676
  enabled: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/taskmaster",
3
- "version": "1.0.65",
3
+ "version": "1.0.67",
4
4
  "description": "AI-powered business assistant for small businesses",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -533,6 +533,8 @@ All files are markdown (`.md`) — plain text with simple formatting. You can up
533
533
 
534
534
  When you add or change a file, your assistant picks it up automatically — no restart needed. The status light on the Files page turns red when files have changed since the last index, so you can see at a glance whether a re-index is needed.
535
535
 
536
+ After a fresh install or upgrade, the embedding model downloads automatically (~640 MB for most devices, larger on devices with more RAM). During the download, a full-screen overlay blocks all navigation with a "Downloading embedding model" message — this is a one-time download and typically takes a few minutes. Memory search is unavailable until the download completes.
537
+
536
538
  When your assistant writes to **public/** or **shared/**, a shield icon appears in the navigation bar so you can review what was written (see [Data Safety Alert](#data-safety-alert) above).
537
539
 
538
540
  ### Searching Memory
@@ -1265,6 +1267,57 @@ If the page loses connection during the update and doesn't reconnect within two
1265
1267
 
1266
1268
  ---
1267
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
+
1268
1321
  ## Uninstalling Taskmaster
1269
1322
 
1270
1323
  ### From the Control Panel