@rubytech/taskmaster 1.9.6 → 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.6",
3
- "commit": "ed36605579b1ba21fa2309986f83cae397e7206a",
4
- "builtAt": "2026-02-28T15:54:48.228Z"
2
+ "version": "1.9.7",
3
+ "commit": "728a31bb44d34f67c214b4a7fbe191f1c72843f2",
4
+ "builtAt": "2026-02-28T16:34:23.647Z"
5
5
  }
@@ -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.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",