@rubytech/taskmaster 1.9.5 → 1.9.7

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,5 @@
1
1
  {
2
- "version": "1.9.5",
3
- "commit": "521c1bca1da1357a98d5afc5f71aafcf2a5831b8",
4
- "builtAt": "2026-02-28T15:36:00.493Z"
2
+ "version": "1.9.7",
3
+ "commit": "728a31bb44d34f67c214b4a7fbe191f1c72843f2",
4
+ "builtAt": "2026-02-28T16:34:23.647Z"
5
5
  }
@@ -122,6 +122,29 @@ export const tailscaleHandlers = {
122
122
  message: typeof errObj.message === "string" ? errObj.message : "",
123
123
  };
124
124
  };
125
+ const needsSudo = (output) => {
126
+ const lower = output.toLowerCase();
127
+ return (lower.includes("access denied") ||
128
+ lower.includes("permission denied") ||
129
+ lower.includes("use 'sudo tailscale") ||
130
+ lower.includes("requires root"));
131
+ };
132
+ // Run `tailscale up` (with optional extra args) and capture output.
133
+ // If the command fails with a permission error, retries with `sudo -n`.
134
+ // `tailscale up` blocks until auth completes, so timeouts are expected —
135
+ // the auth URL appears in stdout/stderr before the timeout kills it.
136
+ const runTailscaleUp = async (binary, extraArgs = []) => {
137
+ const args = ["up", ...extraArgs];
138
+ const execOpts = { timeoutMs: 60_000, maxBuffer: 100_000 };
139
+ const result = await runExec(binary, args, execOpts).catch((err) => captureExecOutput(err));
140
+ const output = `${result.stdout}\n${result.stderr}`;
141
+ if (!needsSudo(output))
142
+ return output;
143
+ // Permission denied — retry with sudo -n (non-interactive)
144
+ context.logGateway.info(`tailscale.enable: permission denied, retrying with sudo: ${args.join(" ")}`);
145
+ const sudoResult = await runExec("sudo", ["-n", binary, ...args], execOpts).catch((err) => captureExecOutput(err));
146
+ return `${sudoResult.stdout}\n${sudoResult.stderr}`;
147
+ };
125
148
  try {
126
149
  const binary = await findTailscaleBinary();
127
150
  if (!binary) {
@@ -149,20 +172,7 @@ export const tailscaleHandlers = {
149
172
  // Other status errors (e.g. NeedsLogin) are fine — continue to `tailscale up`
150
173
  }
151
174
  // ── Attempt 1: `tailscale up` ──
152
- // On a headless device this prints:
153
- // "To authenticate, visit:\n\n\thttps://login.tailscale.com/a/..."
154
- // The command blocks until auth completes, so we run it with a
155
- // timeout and parse the auth URL from the output. The URL appears
156
- // within seconds; the timeout just kills the blocking wait.
157
- const child = await runExec(binary, ["up"], {
158
- timeoutMs: 60_000,
159
- maxBuffer: 100_000,
160
- }).catch((err) => {
161
- // `tailscale up` exits non-zero while waiting for auth but still
162
- // prints the URL to stdout/stderr before the timeout kills it.
163
- return captureExecOutput(err);
164
- });
165
- const combined = `${child.stdout}\n${child.stderr}`;
175
+ const combined = await runTailscaleUp(binary);
166
176
  const authUrl = extractAuthUrl(combined);
167
177
  if (authUrl) {
168
178
  respond(true, { authUrl });
@@ -184,11 +194,7 @@ export const tailscaleHandlers = {
184
194
  // state where plain `tailscale up` doesn't produce a new auth URL.
185
195
  // --force-reauth forces a fresh login flow that always emits a URL.
186
196
  context.logGateway.info("tailscale.enable: no auth URL from initial attempt, retrying with --force-reauth");
187
- const retry = await runExec(binary, ["up", "--force-reauth"], {
188
- timeoutMs: 60_000,
189
- maxBuffer: 100_000,
190
- }).catch((err) => captureExecOutput(err));
191
- const retryCombined = `${retry.stdout}\n${retry.stderr}`;
197
+ const retryCombined = await runTailscaleUp(binary, ["--force-reauth"]);
192
198
  const retryUrl = extractAuthUrl(retryCombined);
193
199
  if (retryUrl) {
194
200
  respond(true, { authUrl: retryUrl });
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Fire-and-forget suggestion generation + broadcast.
3
+ *
4
+ * Called after broadcastChatFinal (follow-up mode) and after
5
+ * chat.history returns empty messages (session-start mode).
6
+ */
7
+ import { resolveAgentWorkspaceDir, resolveSessionAgentId } from "../agents/agent-scope.js";
8
+ import { resolveIdentityName } from "../agents/identity.js";
9
+ import { loadConfig } from "../config/config.js";
10
+ import { loadSessionEntry, readSessionMessages } from "../gateway/session-utils.js";
11
+ import { generateSuggestions } from "./generator.js";
12
+ /** Default model for suggestion generation. */
13
+ const SUGGESTION_MODEL = "claude-haiku-4-5-20251001";
14
+ /** Number of recent messages to include as context for suggestion generation. */
15
+ const RECENT_MESSAGES_LIMIT = 10;
16
+ /** Extract text from a session message (handles both string and structured content). */
17
+ function extractMessageText(msg) {
18
+ if (!msg || typeof msg !== "object")
19
+ return "";
20
+ const m = msg;
21
+ if (typeof m.content === "string")
22
+ return m.content;
23
+ if (Array.isArray(m.content)) {
24
+ return m.content
25
+ .filter((c) => c.type === "text" && typeof c.text === "string")
26
+ .map((c) => c.text)
27
+ .join(" ");
28
+ }
29
+ return "";
30
+ }
31
+ /**
32
+ * Load recent conversation messages for context. Returns a formatted string
33
+ * with the last N messages (user + assistant turns).
34
+ */
35
+ function loadRecentMessages(sessionKey) {
36
+ try {
37
+ const { storePath, entry } = loadSessionEntry(sessionKey);
38
+ if (!entry?.sessionId || !storePath)
39
+ return "";
40
+ const messages = readSessionMessages(entry.sessionId, storePath, entry.sessionFile);
41
+ if (messages.length === 0)
42
+ return "";
43
+ // Take the last N messages
44
+ const recent = messages.slice(-RECENT_MESSAGES_LIMIT);
45
+ return recent
46
+ .map((msg) => {
47
+ const m = msg;
48
+ const role = m.role === "assistant" ? "Assistant" : "User";
49
+ const text = extractMessageText(msg);
50
+ if (!text)
51
+ return null;
52
+ // Truncate long messages
53
+ const truncated = text.length > 200 ? `${text.slice(0, 200)}...` : text;
54
+ return `${role}: ${truncated}`;
55
+ })
56
+ .filter(Boolean)
57
+ .join("\n");
58
+ }
59
+ catch {
60
+ return "";
61
+ }
62
+ }
63
+ /**
64
+ * Generate suggestions and broadcast them. Fire-and-forget — errors are logged, never thrown.
65
+ */
66
+ export function fireSuggestion(params) {
67
+ const { sessionKey, broadcast, cfg, lastUserMessage, lastAssistantReply } = params;
68
+ const config = cfg ?? loadConfig();
69
+ const agentId = resolveSessionAgentId({ sessionKey, config });
70
+ const agentDir = resolveAgentWorkspaceDir(config, agentId);
71
+ const assistantName = resolveIdentityName(config, agentId);
72
+ // Load recent conversation for better context
73
+ const recentMessages = loadRecentMessages(sessionKey);
74
+ if (!recentMessages) {
75
+ console.warn(`[suggestions] no recent messages found for sessionKey=${sessionKey}`);
76
+ }
77
+ void generateSuggestions({
78
+ model: SUGGESTION_MODEL,
79
+ lastUserMessage,
80
+ lastAssistantReply,
81
+ recentMessages,
82
+ assistantName,
83
+ cfg: config,
84
+ agentDir,
85
+ })
86
+ .then((result) => {
87
+ if (result.ok) {
88
+ broadcast("suggestions", {
89
+ sessionKey,
90
+ suggestions: result.texts,
91
+ });
92
+ }
93
+ else {
94
+ console.warn(`[suggestions] generation failed: ${result.error.message}`);
95
+ }
96
+ })
97
+ .catch((err) => {
98
+ console.warn(`[suggestions] unexpected error: ${err instanceof Error ? err.message : String(err)}`);
99
+ });
100
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Suggestion chip generation.
3
+ *
4
+ * Two modes:
5
+ * - Session start: picks from curated opener pairs (no AI call)
6
+ * - Follow-up: uses Haiku to generate two contextual quick-replies (affirmative + negative)
7
+ */
8
+ import path from "node:path";
9
+ import { complete } from "@mariozechner/pi-ai";
10
+ import { AuthStorage, ModelRegistry } from "@mariozechner/pi-coding-agent";
11
+ import { getApiKeyForModel, requireApiKey } from "../agents/model-auth.js";
12
+ import { ensureTaskmasterModelsJson } from "../agents/models-config.js";
13
+ const FOLLOW_UP_SYSTEM_PROMPT = `Output ONLY a JSON array with exactly two strings. No explanation, no markdown, no labels.`;
14
+ /**
15
+ * Curated session-start opener pairs. Picked randomly.
16
+ * Each pair has an affirmative and an alternative option.
17
+ */
18
+ const SESSION_START_OPENER_PAIRS = [
19
+ ["What's on the schedule today?", "Give me a quick briefing"],
20
+ ["Any messages I should catch up on?", "What needs my attention?"],
21
+ ["Help me plan my day", "Anything urgent right now?"],
22
+ ["What's new since yesterday?", "Show me my to-do list"],
23
+ ["Any updates I should know about?", "Let's get started"],
24
+ ];
25
+ /**
26
+ * Pick a random session-start opener pair. No AI call needed.
27
+ */
28
+ export function pickSessionStartSuggestions() {
29
+ const pair = SESSION_START_OPENER_PAIRS[Math.floor(Math.random() * SESSION_START_OPENER_PAIRS.length)];
30
+ return { ok: true, texts: [pair[0], pair[1]] };
31
+ }
32
+ /** Strip surrounding quotes from a string. */
33
+ function stripQuotes(s) {
34
+ let text = s.trim();
35
+ if ((text.startsWith('"') && text.endsWith('"')) ||
36
+ (text.startsWith("'") && text.endsWith("'"))) {
37
+ text = text.slice(1, -1).trim();
38
+ }
39
+ return text;
40
+ }
41
+ /**
42
+ * Generate two follow-up suggestions using Haiku. Requires conversation context.
43
+ */
44
+ export async function generateFollowUpSuggestions(params) {
45
+ const { model: modelId, lastUserMessage, lastAssistantReply, recentMessages, assistantName, timeoutMs = 4000, cfg, agentDir, } = params;
46
+ if (!lastAssistantReply) {
47
+ return { ok: false, error: new Error("Follow-up requires lastAssistantReply") };
48
+ }
49
+ try {
50
+ await ensureTaskmasterModelsJson(cfg, agentDir);
51
+ const authStorage = agentDir
52
+ ? new AuthStorage(path.join(agentDir, "auth.json"))
53
+ : new AuthStorage();
54
+ const modelRegistry = agentDir
55
+ ? new ModelRegistry(authStorage, path.join(agentDir, "models.json"))
56
+ : new ModelRegistry(authStorage);
57
+ const model = modelRegistry.find("anthropic", modelId);
58
+ if (!model) {
59
+ return { ok: false, error: new Error(`Suggestion model not found: anthropic/${modelId}`) };
60
+ }
61
+ const apiKeyInfo = await getApiKeyForModel({ model, cfg, agentDir });
62
+ const apiKey = requireApiKey(apiKeyInfo, model.provider);
63
+ authStorage.setRuntimeApiKey(model.provider, apiKey);
64
+ // Build prompt from conversation history when available, fall back to last exchange
65
+ let prompt;
66
+ // Build the conversation context — prefer full history, fall back to last exchange
67
+ const name = assistantName || "Assistant";
68
+ let conversationContext;
69
+ if (recentMessages) {
70
+ conversationContext = recentMessages;
71
+ }
72
+ else {
73
+ const truncatedReply = lastAssistantReply.length > 300
74
+ ? `${lastAssistantReply.slice(0, 300)}...`
75
+ : lastAssistantReply;
76
+ const truncatedUser = lastUserMessage && lastUserMessage.length > 200
77
+ ? `${lastUserMessage.slice(0, 200)}...`
78
+ : (lastUserMessage ?? "");
79
+ conversationContext = `User: ${truncatedUser}\n${name}: ${truncatedReply}`;
80
+ }
81
+ prompt = `${conversationContext}\n\nPredict the next message from the user. Give me two options that oppose each other if possible.`;
82
+ const context = {
83
+ systemPrompt: FOLLOW_UP_SYSTEM_PROMPT,
84
+ messages: [{ role: "user", content: prompt, timestamp: Date.now() }],
85
+ };
86
+ const timeoutPromise = new Promise((_, reject) => {
87
+ setTimeout(() => reject(new Error("Suggestion generation timeout")), timeoutMs);
88
+ });
89
+ const message = (await Promise.race([
90
+ complete(model, context, {
91
+ apiKey,
92
+ maxTokens: 100,
93
+ temperature: 0.8,
94
+ }),
95
+ timeoutPromise,
96
+ ]));
97
+ const content = message.content?.[0];
98
+ if (!content || content.type !== "text" || !content.text) {
99
+ return { ok: false, error: new Error("Empty suggestion response") };
100
+ }
101
+ // Strip markdown code fences (```json ... ```) before parsing
102
+ let raw = content.text.trim();
103
+ raw = raw
104
+ .replace(/^```(?:json)?\s*\n?/i, "")
105
+ .replace(/\n?```\s*$/, "")
106
+ .trim();
107
+ // Parse JSON array response
108
+ try {
109
+ const parsed = JSON.parse(raw);
110
+ if (Array.isArray(parsed) && parsed.length >= 2) {
111
+ const texts = parsed.slice(0, 2).map((s) => stripQuotes(String(s)));
112
+ if (texts.every((t) => t.length > 0)) {
113
+ return { ok: true, texts };
114
+ }
115
+ }
116
+ }
117
+ catch {
118
+ // If JSON parsing fails, try to extract two lines
119
+ const lines = raw
120
+ .split("\n")
121
+ .map((l) => stripQuotes(l))
122
+ .filter((l) => l.length > 0);
123
+ if (lines.length >= 2) {
124
+ return { ok: true, texts: [lines[0], lines[1]] };
125
+ }
126
+ // Fall back to single suggestion
127
+ if (lines.length === 1) {
128
+ return { ok: true, texts: [lines[0]] };
129
+ }
130
+ }
131
+ return { ok: false, error: new Error("Could not parse suggestion pair from response") };
132
+ }
133
+ catch (err) {
134
+ const error = err instanceof Error ? err : new Error(String(err));
135
+ return { ok: false, error };
136
+ }
137
+ }
138
+ /**
139
+ * Generate suggestions. Delegates to the appropriate strategy based on context.
140
+ */
141
+ export async function generateSuggestions(params) {
142
+ if (params.lastAssistantReply) {
143
+ return generateFollowUpSuggestions(params);
144
+ }
145
+ return pickSessionStartSuggestions();
146
+ }
147
+ // --- Legacy single-suggestion API (preserved for backwards compatibility) ---
148
+ /**
149
+ * Pick a random session-start opener. No AI call needed.
150
+ */
151
+ export function pickSessionStartSuggestion() {
152
+ const result = pickSessionStartSuggestions();
153
+ if (!result.ok)
154
+ return result;
155
+ return { ok: true, text: result.texts[0] };
156
+ }
157
+ /**
158
+ * Generate a suggestion. Delegates to the appropriate strategy based on context.
159
+ */
160
+ export async function generateSuggestion(params) {
161
+ const result = await generateSuggestions(params);
162
+ if (!result.ok)
163
+ return result;
164
+ return { ok: true, text: result.texts[0] };
165
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Generate a proactive greeting for the public chat widget.
3
+ *
4
+ * Uses Haiku to create a short, friendly first message based on the agent's
5
+ * identity (name + SOUL.md persona). If no model/key is available, falls back
6
+ * to a generic greeting.
7
+ */
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ import { complete } from "@mariozechner/pi-ai";
11
+ import { AuthStorage, ModelRegistry } from "@mariozechner/pi-coding-agent";
12
+ import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
13
+ import { resolveIdentityName } from "../agents/identity.js";
14
+ import { DEFAULT_SOUL_FILENAME } from "../agents/workspace.js";
15
+ import { getApiKeyForModel, requireApiKey } from "../agents/model-auth.js";
16
+ import { ensureTaskmasterModelsJson } from "../agents/models-config.js";
17
+ const GREETING_MODEL = "claude-haiku-4-5-20251001";
18
+ const SYSTEM_PROMPT = `You write a single short, friendly greeting that an AI assistant would use to start a conversation with a website visitor. Output ONLY the greeting text — no quotes, no prefix, no explanation.
19
+
20
+ Rules:
21
+ 1. Keep it warm, approachable, and natural (10-25 words)
22
+ 2. Reflect the assistant's personality and role if context is provided
23
+ 3. Invite the visitor to ask a question or start a conversation
24
+ 4. Never mention being an AI or chatbot — just be helpful
25
+ 5. Do not use emojis`;
26
+ export async function generateGreeting(params) {
27
+ const { cfg, agentId } = params;
28
+ const agentDir = resolveAgentWorkspaceDir(cfg, agentId);
29
+ const name = resolveIdentityName(cfg, agentId) ?? "Assistant";
30
+ // Read SOUL.md for persona context (if present)
31
+ let soulContext = "";
32
+ try {
33
+ const soulPath = path.join(agentDir, DEFAULT_SOUL_FILENAME);
34
+ if (fs.existsSync(soulPath)) {
35
+ const raw = fs.readFileSync(soulPath, "utf8").trim();
36
+ if (raw.length > 0) {
37
+ soulContext = raw.length > 500 ? raw.slice(0, 500) + "..." : raw;
38
+ }
39
+ }
40
+ }
41
+ catch {
42
+ // Ignore — SOUL.md is optional
43
+ }
44
+ try {
45
+ await ensureTaskmasterModelsJson(cfg, agentDir);
46
+ const authStorage = new AuthStorage(path.join(agentDir, "auth.json"));
47
+ const modelRegistry = new ModelRegistry(authStorage, path.join(agentDir, "models.json"));
48
+ const model = modelRegistry.find("anthropic", GREETING_MODEL);
49
+ if (!model) {
50
+ return fallbackGreeting(name);
51
+ }
52
+ const apiKeyInfo = await getApiKeyForModel({ model, cfg, agentDir });
53
+ const apiKey = requireApiKey(apiKeyInfo, model.provider);
54
+ authStorage.setRuntimeApiKey(model.provider, apiKey);
55
+ const prompt = soulContext
56
+ ? `Assistant name: "${name}"\n\nPersona/role:\n${soulContext}\n\nGenerate a greeting:`
57
+ : `Assistant name: "${name}"\n\nGenerate a greeting:`;
58
+ const context = {
59
+ systemPrompt: SYSTEM_PROMPT,
60
+ messages: [{ role: "user", content: prompt, timestamp: Date.now() }],
61
+ };
62
+ const message = (await Promise.race([
63
+ complete(model, context, {
64
+ apiKey,
65
+ maxTokens: 80,
66
+ temperature: 0.7,
67
+ }),
68
+ new Promise((_, reject) => setTimeout(() => reject(new Error("Greeting generation timeout")), 8000)),
69
+ ]));
70
+ const content = message.content?.[0];
71
+ if (!content || content.type !== "text" || !content.text) {
72
+ return fallbackGreeting(name);
73
+ }
74
+ let text = content.text.trim();
75
+ // Strip quotes if wrapped
76
+ if ((text.startsWith('"') && text.endsWith('"')) ||
77
+ (text.startsWith("'") && text.endsWith("'"))) {
78
+ text = text.slice(1, -1).trim();
79
+ }
80
+ return { ok: true, greeting: text };
81
+ }
82
+ catch {
83
+ return fallbackGreeting(name);
84
+ }
85
+ }
86
+ function fallbackGreeting(name) {
87
+ return { ok: true, greeting: `Hi, I'm ${name}. How can I help you today?` };
88
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/taskmaster",
3
- "version": "1.9.5",
3
+ "version": "1.9.7",
4
4
  "description": "AI-powered business assistant for small businesses",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -75,7 +75,8 @@
75
75
  "dist/whatsapp/**",
76
76
  "dist/records/**",
77
77
  "dist/filler/**",
78
- "dist/license/**"
78
+ "dist/license/**",
79
+ "dist/suggestions/**"
79
80
  ],
80
81
  "scripts": {
81
82
  "dev": "node scripts/run-node.mjs",