@rubytech/taskmaster 1.0.67 → 1.0.69

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-D5tMNwub.js"></script>
10
+ <link rel="stylesheet" crossorigin href="./assets/index-BCh3mx9Z.css">
11
11
  </head>
12
12
  <body>
13
13
  <taskmaster-app></taskmaster-app>
@@ -387,11 +387,16 @@ export function handlePublicChatHttpRequest(req, res, opts) {
387
387
  res.end("Method Not Allowed");
388
388
  return true;
389
389
  }
390
- // Only /public/chat (the SPA route)
391
- if (pathname !== "/public/chat" && !pathname.startsWith("/public/chat/")) {
390
+ // Only /public/chat/:accountId (the SPA route)
391
+ if (!pathname.startsWith("/public/chat/")) {
392
392
  // /public/widget.js is handled separately
393
393
  if (pathname === "/public/widget.js")
394
394
  return false;
395
+ // Bare /public/chat without accountId → 404
396
+ if (pathname === "/public/chat") {
397
+ respondNotFound(res);
398
+ return true;
399
+ }
395
400
  respondNotFound(res);
396
401
  return true;
397
402
  }
@@ -400,6 +405,12 @@ export function handlePublicChatHttpRequest(req, res, opts) {
400
405
  respondNotFound(res);
401
406
  return true;
402
407
  }
408
+ // Extract accountId from /public/chat/:accountId
409
+ const accountId = pathname.slice("/public/chat/".length).split("/")[0]?.trim();
410
+ if (!accountId || !/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(accountId)) {
411
+ respondNotFound(res);
412
+ return true;
413
+ }
403
414
  const root = resolveControlUiRoot();
404
415
  if (!root) {
405
416
  res.statusCode = 503;
@@ -412,7 +423,7 @@ export function handlePublicChatHttpRequest(req, res, opts) {
412
423
  respondNotFound(res);
413
424
  return true;
414
425
  }
415
- const publicAgentId = resolvePublicAgentId(config);
426
+ const publicAgentId = resolvePublicAgentId(config, accountId);
416
427
  const identity = resolveAssistantIdentity({ cfg: config, agentId: publicAgentId });
417
428
  // Only inject avatar if it resolves to an actual image URL/path (not a
418
429
  // single-letter fallback like "D" which would render as a broken <img>).
@@ -421,7 +432,7 @@ export function handlePublicChatHttpRequest(req, res, opts) {
421
432
  agentId: publicAgentId,
422
433
  basePath: "",
423
434
  });
424
- const avatarValue = resolvedAvatar && (/^(https?:\/\/|data:image\/|\/)/i.test(resolvedAvatar))
435
+ const avatarValue = resolvedAvatar && /^(https?:\/\/|data:image\/|\/)/i.test(resolvedAvatar)
425
436
  ? resolvedAvatar
426
437
  : undefined;
427
438
  const authMode = config.publicChat.auth ?? "anonymous";
@@ -438,15 +449,23 @@ export function handlePublicChatHttpRequest(req, res, opts) {
438
449
  brandIconUrl,
439
450
  accentColor,
440
451
  });
441
- // Inject public-chat globals
452
+ // Inject <base href="/"> right after <head> so relative asset paths (./assets/...)
453
+ // resolve from root. The URL is /public/chat/:accountId — 3 levels deep, so without
454
+ // <base> the browser would look for /public/chat/assets/... which doesn't exist.
455
+ // The <base> tag MUST appear before any tags that use relative URLs.
456
+ const headOpen = injected.indexOf("<head>");
457
+ const baseInjected = headOpen !== -1
458
+ ? `${injected.slice(0, headOpen + 6)}<base href="/">${injected.slice(headOpen + 6)}`
459
+ : injected;
460
+ // Inject public-chat globals before </head>
442
461
  const publicScript = `<script>` +
443
462
  `window.__TASKMASTER_PUBLIC_CHAT__=true;` +
444
- `window.__TASKMASTER_PUBLIC_CHAT_CONFIG__=${JSON.stringify({ auth: authMode, cookieTtlDays })};` +
463
+ `window.__TASKMASTER_PUBLIC_CHAT_CONFIG__=${JSON.stringify({ accountId, auth: authMode, cookieTtlDays })};` +
445
464
  `</script>`;
446
- const headClose = injected.indexOf("</head>");
465
+ const headClose = baseInjected.indexOf("</head>");
447
466
  const withPublic = headClose !== -1
448
- ? `${injected.slice(0, headClose)}${publicScript}${injected.slice(headClose)}`
449
- : `${publicScript}${injected}`;
467
+ ? `${baseInjected.slice(0, headClose)}${publicScript}${baseInjected.slice(headClose)}`
468
+ : `${publicScript}${baseInjected}`;
450
469
  res.setHeader("Content-Type", "text/html; charset=utf-8");
451
470
  res.setHeader("Cache-Control", "no-cache");
452
471
  res.end(withPublic);
@@ -455,12 +474,13 @@ export function handlePublicChatHttpRequest(req, res, opts) {
455
474
  /** Widget script content — self-contained JS for embedding. */
456
475
  const WIDGET_SCRIPT = `(function(){
457
476
  "use strict";
458
- var cfg={server:""};
477
+ var cfg={server:"",accountId:""};
459
478
  var isOpen=false;
460
479
  var btn,overlay,iframe;
461
480
 
462
481
  function init(opts){
463
482
  if(opts&&opts.server) cfg.server=opts.server.replace(/\\/$/,"");
483
+ if(opts&&opts.accountId) cfg.accountId=opts.accountId;
464
484
  build();
465
485
  }
466
486
 
@@ -495,7 +515,7 @@ const WIDGET_SCRIPT = `(function(){
495
515
  overlay.className="tm-widget-overlay";
496
516
  iframe=document.createElement("iframe");
497
517
  iframe.className="tm-widget-iframe";
498
- iframe.src=cfg.server+"/public/chat";
518
+ iframe.src=cfg.server+"/public/chat/"+encodeURIComponent(cfg.accountId);
499
519
  overlay.appendChild(iframe);
500
520
  document.body.appendChild(overlay);
501
521
  }
@@ -1,39 +1,28 @@
1
1
  /**
2
2
  * Resolve public-chat session keys (anonymous and verified).
3
3
  *
4
- * The public agent is the agent handling WhatsApp DMs for the default account.
4
+ * The public agent is the agent handling unknown WhatsApp DMs for a given account.
5
+ * The account is determined by the URL path: /public/chat/:accountId.
5
6
  * Anonymous sessions use a cookie-based identifier; verified sessions use the
6
7
  * phone number so they share the same DM session as WhatsApp.
7
8
  */
8
- import { resolveDefaultAgentId } from "../../agents/agent-scope.js";
9
+ import { resolveAgentRoute } from "../../routing/resolve-route.js";
9
10
  import { normalizeAgentId } from "../../routing/session-key.js";
10
11
  /**
11
- * Find the agent that handles public-facing WhatsApp DMs.
12
- * Priority: binding to whatsapp DM > agent named "public" > default agent.
12
+ * Find the agent that handles public-facing WhatsApp DMs for the given account.
13
+ *
14
+ * Uses the same routing logic as WhatsApp itself: calls resolveAgentRoute
15
+ * with a synthetic unknown-peer DM. This guarantees the public chat routes
16
+ * to the exact same agent that handles unknown WhatsApp DMs for that account.
13
17
  */
14
- 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);
18
+ export function resolvePublicAgentId(cfg, accountId) {
19
+ const route = resolveAgentRoute({
20
+ cfg,
21
+ channel: "whatsapp",
22
+ accountId,
23
+ peer: { kind: "dm", id: "__public_chat__" },
24
+ });
25
+ return normalizeAgentId(route.agentId);
37
26
  }
38
27
  /**
39
28
  * Build the session key for a public-chat visitor.
@@ -11,9 +11,7 @@ import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.j
11
11
  import { extractShortModelName, } from "../../auto-reply/reply/response-prefix-template.js";
12
12
  import { resolveSendPolicy } from "../../sessions/send-policy.js";
13
13
  import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js";
14
- import { loadConfig as loadConfigFn } from "../../config/config.js";
15
14
  import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
16
- import { resolvePublicAgentId } from "../public-chat/session.js";
17
15
  import { abortChatRunById, abortChatRunsForSessionKey, isChatStopCommandText, resolveChatRunExpiresAtMs, } from "../chat-abort.js";
18
16
  import { ErrorCodes, errorShape, formatValidationErrors, validateChatAbortParams, validateChatHistoryParams, validateChatInjectParams, validateChatSendParams, } from "../protocol/index.js";
19
17
  import { loadSessionEntry, readSessionMessages, resolveSessionModelRef } from "../session-utils.js";
@@ -127,10 +125,9 @@ function broadcastChatError(params) {
127
125
  function validatePublicSessionAccess(role, sessionKey) {
128
126
  if (role !== "public")
129
127
  return null;
130
- const cfg = loadConfigFn();
131
- const publicAgentId = resolvePublicAgentId(cfg);
132
- const prefix = `agent:${publicAgentId}:dm:`;
133
- if (!sessionKey.startsWith(prefix)) {
128
+ // Public clients can only access agent DM sessions (issued by public.session / public.otp.verify).
129
+ // Format: agent:{agentId}:dm:{identifier}
130
+ if (!/^agent:[^:]+:dm:/.test(sessionKey)) {
134
131
  return errorShape(ErrorCodes.INVALID_REQUEST, "unauthorized session key");
135
132
  }
136
133
  return null;
@@ -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,
@@ -10,6 +10,14 @@ import { buildPublicSessionKey, resolvePublicAgentId } from "../public-chat/sess
10
10
  function isValidPhone(phone) {
11
11
  return /^\+\d{7,15}$/.test(phone);
12
12
  }
13
+ function validateAccountId(raw) {
14
+ if (typeof raw !== "string")
15
+ return null;
16
+ const trimmed = raw.trim();
17
+ if (!trimmed || !/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(trimmed))
18
+ return null;
19
+ return trimmed;
20
+ }
13
21
  export const publicChatHandlers = {
14
22
  /**
15
23
  * Request an OTP code — sends a 6-digit code to the given phone via WhatsApp.
@@ -43,12 +51,13 @@ export const publicChatHandlers = {
43
51
  },
44
52
  /**
45
53
  * Verify an OTP code and return the session key.
46
- * Params: { phone: string, code: string, name?: string }
54
+ * Params: { phone: string, code: string, accountId: string, name?: string }
47
55
  */
48
56
  "public.otp.verify": async ({ params, respond }) => {
49
57
  const phone = typeof params.phone === "string" ? params.phone.trim() : "";
50
58
  const code = typeof params.code === "string" ? params.code.trim() : "";
51
59
  const name = typeof params.name === "string" ? params.name.trim() : undefined;
60
+ const accountId = validateAccountId(params.accountId);
52
61
  if (!phone || !isValidPhone(phone)) {
53
62
  respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "invalid phone number"));
54
63
  return;
@@ -57,6 +66,10 @@ export const publicChatHandlers = {
57
66
  respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "code required"));
58
67
  return;
59
68
  }
69
+ if (!accountId) {
70
+ respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "accountId required"));
71
+ return;
72
+ }
60
73
  const cfg = loadConfig();
61
74
  if (!cfg.publicChat?.enabled) {
62
75
  respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "public chat disabled"));
@@ -73,7 +86,7 @@ export const publicChatHandlers = {
73
86
  respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, messages[result.error] ?? "verification failed"));
74
87
  return;
75
88
  }
76
- const agentId = resolvePublicAgentId(cfg);
89
+ const agentId = resolvePublicAgentId(cfg, accountId);
77
90
  const sessionKey = buildPublicSessionKey(agentId, phone);
78
91
  respond(true, {
79
92
  ok: true,
@@ -85,20 +98,25 @@ export const publicChatHandlers = {
85
98
  },
86
99
  /**
87
100
  * Resolve a session key for anonymous public chat.
88
- * Params: { cookieId: string }
101
+ * Params: { cookieId: string, accountId: string }
89
102
  */
90
103
  "public.session": async ({ params, respond }) => {
91
104
  const cookieId = typeof params.cookieId === "string" ? params.cookieId.trim() : "";
105
+ const accountId = validateAccountId(params.accountId);
92
106
  if (!cookieId || cookieId.length < 8 || cookieId.length > 128) {
93
107
  respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "invalid cookieId"));
94
108
  return;
95
109
  }
110
+ if (!accountId) {
111
+ respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "accountId required"));
112
+ return;
113
+ }
96
114
  const cfg = loadConfig();
97
115
  if (!cfg.publicChat?.enabled) {
98
116
  respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "public chat disabled"));
99
117
  return;
100
118
  }
101
- const agentId = resolvePublicAgentId(cfg);
119
+ const agentId = resolvePublicAgentId(cfg, accountId);
102
120
  const identifier = `anon-${cookieId}`;
103
121
  const sessionKey = buildPublicSessionKey(agentId, identifier);
104
122
  respond(true, {
@@ -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: {
@@ -2,21 +2,19 @@ import fsSync from "node:fs";
2
2
  import os from "node:os";
3
3
  import { createSubsystemLogger } from "../logging/subsystem.js";
4
4
  import { resolveUserPath } from "../utils.js";
5
- import { getCustomProviderApiKey } from "../agents/model-auth.js";
6
5
  import { createGeminiEmbeddingProvider } from "./embeddings-gemini.js";
7
6
  import { createOpenAiEmbeddingProvider } from "./embeddings-openai.js";
8
7
  import { importNodeLlamaCpp } from "./node-llama.js";
9
8
  /**
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.
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.
16
14
  */
17
15
  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",
16
+ model: "hf:ggml-org/embeddinggemma-300M-Q8_0-GGUF/embeddinggemma-300M-Q8_0.gguf",
17
+ label: "embeddinggemma-300M",
20
18
  };
21
19
  function selectDefaultLocalModel() {
22
20
  return DEFAULT_LOCAL_MODEL;
@@ -106,7 +104,7 @@ async function createLocalEmbeddingProvider(options) {
106
104
  else {
107
105
  const selected = selectDefaultLocalModel();
108
106
  modelPath = selected.model;
109
- log.info(`selected tier ${selected.label} (system RAM: ${(os.totalmem() / (1024 ** 3)).toFixed(1)} GB)`);
107
+ log.info(`selected tier ${selected.label} (system RAM: ${(os.totalmem() / 1024 ** 3).toFixed(1)} GB)`);
110
108
  }
111
109
  const modelCacheDir = options.local?.modelCacheDir?.trim();
112
110
  // Lazy-load node-llama-cpp to keep startup light unless local is enabled.
@@ -188,15 +186,16 @@ export async function createEmbeddingProvider(options) {
188
186
  }
189
187
  // 2. Try remote providers — preferred when API keys are available
190
188
  // (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
189
+ // Only consider providers whose key is in config.apiKeys.
190
+ // Environment variables are ignored for auto-selection to avoid
193
191
  // stale dev keys that would fail at runtime.
192
+ const configKeys = options.config.apiKeys ?? {};
194
193
  const remoteProviders = [
195
194
  { id: "openai", configKey: "openai" },
196
195
  { id: "gemini", configKey: "google" },
197
196
  ];
198
197
  for (const { id, configKey } of remoteProviders) {
199
- if (!getCustomProviderApiKey(options.config, configKey))
198
+ if (!configKeys[configKey]?.trim())
200
199
  continue;
201
200
  try {
202
201
  const result = await createProvider(id);
@@ -214,9 +213,7 @@ export async function createEmbeddingProvider(options) {
214
213
  catch (err) {
215
214
  errors.push(formatLocalSetupError(err));
216
215
  }
217
- throw new Error(errors.length > 0
218
- ? errors.join("\n\n")
219
- : "No embeddings provider available.");
216
+ throw new Error(errors.length > 0 ? errors.join("\n\n") : "No embeddings provider available.");
220
217
  }
221
218
  try {
222
219
  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,22 @@ 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 = {
519
+ completed: update.completed,
520
+ total: update.total,
521
+ label: update.label,
522
+ };
523
+ outerProgress?.(update);
524
+ },
525
+ };
526
+ this.syncing = this.runSync(wrappedParams).finally(() => {
513
527
  this.syncing = null;
528
+ this.syncProgress = null;
514
529
  });
515
530
  return this.syncing;
516
531
  }
@@ -663,6 +678,8 @@ export class MemoryIndexManager {
663
678
  files: files?.c ?? 0,
664
679
  chunks: chunks?.c ?? 0,
665
680
  dirty: this.dirty,
681
+ syncing: this.syncing !== null,
682
+ syncProgress: this.syncProgress ?? undefined,
666
683
  workspaceDir: this.workspaceDir,
667
684
  dbPath: this.settings.store.path,
668
685
  provider: this.provider.id,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/taskmaster",
3
- "version": "1.0.67",
3
+ "version": "1.0.69",
4
4
  "description": "AI-powered business assistant for small businesses",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -514,7 +514,7 @@ You get 1,000 free search credits per month — more than enough for daily busin
514
514
  4. **Copy the key** (it starts with `AIza`)
515
515
  5. Go to your Taskmaster **Setup** page, click the **API Keys** row, find **Google**, paste the key, and click **Save**
516
516
 
517
- The free tier is generous no credit card required and sufficient for small business use.
517
+ The free tier works for low-volume use (a handful of voice notes or images per day). For higher volumes, you'll need to enable billing in your Google Cloud account — see [Google AI pricing](https://ai.google.dev/pricing) for details.
518
518
 
519
519
  ---
520
520
 
@@ -533,7 +533,7 @@ 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.
536
+ After a fresh install or upgrade, the embedding model downloads automatically (~330 MB). 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
537
 
538
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).
539
539