@rubytech/taskmaster 1.9.6 → 1.9.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.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.9.6",
3
- "commit": "ed36605579b1ba21fa2309986f83cae397e7206a",
4
- "builtAt": "2026-02-28T15:54:48.228Z"
2
+ "version": "1.9.8",
3
+ "commit": "a48e53e96c2fbfbbc56d274e979d2c7e540ef88d",
4
+ "builtAt": "2026-02-28T16:44:03.157Z"
5
5
  }
@@ -122,15 +122,35 @@ export const tailscaleHandlers = {
122
122
  message: typeof errObj.message === "string" ? errObj.message : "",
123
123
  };
124
124
  };
125
- const needsSudo = (output) => {
125
+ const isPermissionDenied = (output) => {
126
126
  const lower = output.toLowerCase();
127
127
  return (lower.includes("access denied") ||
128
128
  lower.includes("permission denied") ||
129
129
  lower.includes("use 'sudo tailscale") ||
130
130
  lower.includes("requires root"));
131
131
  };
132
+ // Ensure the gateway's OS user is set as the Tailscale operator.
133
+ // This is a persistent setting — once applied, `tailscale up` works
134
+ // without root for the lifetime of the Tailscale install.
135
+ const ensureOperator = async (binary) => {
136
+ const whoami = (await runExec("whoami", [], { timeoutMs: 3_000 }).catch(() => ({ stdout: "" }))).stdout.trim();
137
+ if (!whoami)
138
+ return false;
139
+ context.logGateway.info(`tailscale.enable: setting Tailscale operator to "${whoami}"`);
140
+ try {
141
+ await runExec("sudo", ["-n", binary, "set", `--operator=${whoami}`], {
142
+ timeoutMs: 10_000,
143
+ });
144
+ return true;
145
+ }
146
+ catch {
147
+ context.logGateway.warn("tailscale.enable: failed to set operator (sudo -n not available)");
148
+ return false;
149
+ }
150
+ };
132
151
  // Run `tailscale up` (with optional extra args) and capture output.
133
- // If the command fails with a permission error, retries with `sudo -n`.
152
+ // If the command fails with a permission error, sets the current user
153
+ // as Tailscale operator (permanent fix) and retries without sudo.
134
154
  // `tailscale up` blocks until auth completes, so timeouts are expected —
135
155
  // the auth URL appears in stdout/stderr before the timeout kills it.
136
156
  const runTailscaleUp = async (binary, extraArgs = []) => {
@@ -138,10 +158,17 @@ export const tailscaleHandlers = {
138
158
  const execOpts = { timeoutMs: 60_000, maxBuffer: 100_000 };
139
159
  const result = await runExec(binary, args, execOpts).catch((err) => captureExecOutput(err));
140
160
  const output = `${result.stdout}\n${result.stderr}`;
141
- if (!needsSudo(output))
161
+ if (!isPermissionDenied(output))
142
162
  return output;
143
- // Permission denied — retry with sudo -n (non-interactive)
144
- context.logGateway.info(`tailscale.enable: permission denied, retrying with sudo: ${args.join(" ")}`);
163
+ // Permission denied — fix permanently by setting operator, then retry
164
+ const fixed = await ensureOperator(binary);
165
+ if (fixed) {
166
+ // Operator set — retry without sudo (clean output capture)
167
+ const retryResult = await runExec(binary, args, execOpts).catch((err) => captureExecOutput(err));
168
+ return `${retryResult.stdout}\n${retryResult.stderr}`;
169
+ }
170
+ // Fallback: direct sudo attempt
171
+ context.logGateway.info(`tailscale.enable: falling back to sudo: ${args.join(" ")}`);
145
172
  const sudoResult = await runExec("sudo", ["-n", binary, ...args], execOpts).catch((err) => captureExecOutput(err));
146
173
  return `${sudoResult.stdout}\n${sudoResult.stderr}`;
147
174
  };
@@ -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.6",
3
+ "version": "1.9.8",
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",