myavana-bot-test-core 2.2.0 → 2.3.0

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.
@@ -0,0 +1,122 @@
1
+ // agentMemory.js
2
+ // Local Agent Memory (LAM) — a self-hosted, TAM-inspired long-term memory layer
3
+ // built entirely on this Postgres instance (pgvector), so retrieval never leaves
4
+ // the existing AWS/GCP infra. Extraction follows the same LLM-structured-extraction
5
+ // pattern already used by postProcessConversation/classifyAndSavePersona.
6
+ const ai = require('./ai');
7
+ const { googleAI } = require('@genkit-ai/google-genai');
8
+ const { pgClient } = require('./database');
9
+
10
+ const EMBEDDER = googleAI.embedder('gemini-embedding-001'); // 768-dim output, matches agent_memories.embedding
11
+ const DUPLICATE_DISTANCE_THRESHOLD = 0.15; // cosine distance below this = treat as the same memory
12
+
13
+ async function embedText(text) {
14
+ const [{ embedding }] = await ai.embed({ embedder: EMBEDDER, content: text });
15
+ return `[${embedding.join(',')}]`;
16
+ }
17
+
18
+ const extractionPrompt = (chatHistory, latestMessage) => `
19
+ You are extracting durable, long-term memory facts about a hair-care customer from a conversation, for future personalization.
20
+
21
+ Only extract facts likely still true weeks or months from now (hair type/texture, product preferences or dislikes, allergies/sensitivities, long-term goals, established routines). Do NOT extract one-off statements, small talk, or anything already obviously covered by a generic hair-care questionnaire answer.
22
+
23
+ Conversation history:
24
+ ${JSON.stringify(chatHistory)}
25
+
26
+ User's latest message:
27
+ "${latestMessage}"
28
+
29
+ Return a JSON array (0 to 3 items) of objects: { "content": string, "memory_type": "fact" | "preference" | "episodic", "importance_score": number between 0 and 1 }. Return an empty array if nothing durable was mentioned.
30
+ `;
31
+
32
+ /**
33
+ * Extracts durable facts/preferences from the latest turn and stores them,
34
+ * deduping against the user's existing memories by embedding similarity.
35
+ * Non-blocking, best-effort — call from the async post-processing step.
36
+ *
37
+ * @param {string} userId
38
+ * @param {string} conversationId
39
+ * @param {Array<{role:string, content:string}>} chatHistory
40
+ */
41
+ const extractAndStoreMemories = async (userId, conversationId, chatHistory) => {
42
+ try {
43
+ const latestUserTurn = [...chatHistory].reverse().find(m => m.role === 'user');
44
+ if (!latestUserTurn?.content) return;
45
+
46
+ const chat = ai.chat({
47
+ model: googleAI.model('gemini-3.6-flash'),
48
+ config: { temperature: 0.3 },
49
+ });
50
+ const { output } = await chat.send(extractionPrompt(chatHistory, latestUserTurn.content));
51
+
52
+ const candidates = Array.isArray(output) ? output : [];
53
+ for (const candidate of candidates) {
54
+ if (!candidate?.content) continue;
55
+ await upsertMemory(userId, conversationId, candidate);
56
+ }
57
+ } catch (err) {
58
+ console.error('Error extracting agent memories:', err);
59
+ }
60
+ };
61
+
62
+ async function upsertMemory(userId, conversationId, candidate) {
63
+ const vectorLiteral = await embedText(candidate.content);
64
+
65
+ const existing = await pgClient.query(
66
+ `SELECT id, embedding <=> $2::vector AS distance
67
+ FROM agent_memories
68
+ WHERE user_id = $1
69
+ ORDER BY embedding <=> $2::vector
70
+ LIMIT 1`,
71
+ [userId, vectorLiteral]
72
+ );
73
+
74
+ if (existing.rows.length > 0 && existing.rows[0].distance < DUPLICATE_DISTANCE_THRESHOLD) {
75
+ await pgClient.query(
76
+ `UPDATE agent_memories SET last_accessed_at = NOW(), access_count = access_count + 1 WHERE id = $1`,
77
+ [existing.rows[0].id]
78
+ );
79
+ return;
80
+ }
81
+
82
+ await pgClient.query(
83
+ `INSERT INTO agent_memories (user_id, memory_type, content, embedding, importance_score, source_conversation_id)
84
+ VALUES ($1, $2, $3, $4::vector, $5, $6)`,
85
+ [
86
+ userId,
87
+ candidate.memory_type || 'fact',
88
+ candidate.content,
89
+ vectorLiteral,
90
+ typeof candidate.importance_score === 'number' ? candidate.importance_score : 0.5,
91
+ conversationId,
92
+ ]
93
+ );
94
+ }
95
+
96
+ /**
97
+ * Retrieves the k memories most semantically relevant to queryText for this user.
98
+ *
99
+ * @param {string} userId
100
+ * @param {string} queryText - typically the current user message
101
+ * @param {number} [k=5]
102
+ * @returns {Promise<string[]>}
103
+ */
104
+ const retrieveRelevantMemories = async (userId, queryText, k = 5) => {
105
+ if (!queryText) return [];
106
+ try {
107
+ const vectorLiteral = await embedText(queryText);
108
+ const res = await pgClient.query(
109
+ `SELECT content FROM agent_memories WHERE user_id = $1 ORDER BY embedding <=> $2::vector LIMIT $3`,
110
+ [userId, vectorLiteral, k]
111
+ );
112
+ return res.rows.map(row => row.content);
113
+ } catch (err) {
114
+ console.error('Error retrieving agent memories:', err);
115
+ return [];
116
+ }
117
+ };
118
+
119
+ module.exports = {
120
+ extractAndStoreMemories,
121
+ retrieveRelevantMemories,
122
+ };
@@ -0,0 +1,236 @@
1
+ // agentTools.js
2
+ // Defines Genkit tools that the AI can call during conversations.
3
+ //
4
+ // Two categories:
5
+ // Global tools — stateless DB lookups, registered once at module load.
6
+ // Dynamic tools — user-scoped actions (saveHairGoal, getUserHairProfile)
7
+ // created per-request so userId is bound in the closure,
8
+ // not supplied by the model (avoids hallucination risk).
9
+ //
10
+ // Usage in index.js:
11
+ // const { globalTools, createUserTools } = require('...agentTools');
12
+ // const tools = [...globalTools, ...createUserTools(userId)];
13
+ // session.chat({ ..., tools });
14
+
15
+ const { z } = require('zod');
16
+ const ai = require('./ai');
17
+ const { pgClient } = require('./database');
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Global tools (no user context — safe to register once)
21
+ // ---------------------------------------------------------------------------
22
+
23
+ /**
24
+ * Search Myavana services and partner products by keyword.
25
+ * ILIKE match against name, description, and category columns.
26
+ * Partner products are stored in a separate table once available.
27
+ */
28
+ const searchServices = ai.defineTool(
29
+ {
30
+ name: 'searchServices',
31
+ description:
32
+ 'Search Myavana services and partner products by keyword, hair concern, or category. ' +
33
+ 'Call this when the user asks what services Myavana offers, asks for product or service recommendations, ' +
34
+ 'or mentions a specific concern (e.g. "moisture", "curl analysis", "damage repair", "membership"). ' +
35
+ 'Returns matching service/product names, descriptions, and any pricing or URL info available.',
36
+ inputSchema: z.object({
37
+ query: z.string().describe(
38
+ 'Search keyword — hair concern, service type, or product name ' +
39
+ '(e.g. "moisture treatment", "analysis", "subscription")'
40
+ ),
41
+ limit: z.number().int().min(1).max(10).optional().describe(
42
+ 'Max number of results to return (default 5)'
43
+ ),
44
+ }),
45
+ outputSchema: z.array(z.record(z.unknown())),
46
+ },
47
+ async ({ query, limit = 5 }) => {
48
+ const pattern = `%${query}%`;
49
+ try {
50
+ // Search Myavana services
51
+ const servicesResult = await pgClient.query(
52
+ `SELECT * FROM products
53
+ WHERE product_name ILIKE $1
54
+ OR description ILIKE $1
55
+ OR category ILIKE $1
56
+ LIMIT $2`,
57
+ [pattern, limit]
58
+ );
59
+
60
+ // Search partner products if table exists
61
+ let partnerRows = [];
62
+ try {
63
+ const partnerResult = await pgClient.query(
64
+ `SELECT *, 'partner' AS source FROM partner_products
65
+ WHERE product_name ILIKE $1
66
+ OR description ILIKE $1
67
+ OR brand_name ILIKE $1
68
+ LIMIT $2`,
69
+ [pattern, limit]
70
+ );
71
+ partnerRows = partnerResult.rows;
72
+ } catch {
73
+ // partner_products table doesn't exist yet — skip silently
74
+ }
75
+
76
+ const combined = [...servicesResult.rows, ...partnerRows].slice(0, limit);
77
+ return combined.length > 0
78
+ ? combined
79
+ : [{ message: `No services or products found matching "${query}".` }];
80
+ } catch (err) {
81
+ console.error('searchServices tool error:', err.message);
82
+ return [{ error: 'Service search temporarily unavailable.' }];
83
+ }
84
+ }
85
+ );
86
+
87
+ /**
88
+ * Search FAQs by keyword.
89
+ * ILIKE match against both question and answer text so the AI can ground
90
+ * brand-specific factual answers rather than guessing.
91
+ */
92
+ const lookupFAQ = ai.defineTool(
93
+ {
94
+ name: 'lookupFAQ',
95
+ description:
96
+ 'Look up Myavana FAQ entries by keyword. ' +
97
+ 'Call this when the user asks a factual question about Myavana (pricing, policies, how something works, ' +
98
+ 'shipping, cancellations, what is included in a service) where you want to ground your answer in ' +
99
+ 'official content rather than inference.',
100
+ inputSchema: z.object({
101
+ query: z.string().describe(
102
+ 'The topic or question to search for in FAQs (e.g. "cancel subscription", "how does analysis work")'
103
+ ),
104
+ limit: z.number().int().min(1).max(5).optional().describe(
105
+ 'Max results to return (default 3)'
106
+ ),
107
+ }),
108
+ outputSchema: z.array(z.object({
109
+ question: z.string().optional(),
110
+ answer: z.string().optional(),
111
+ }).passthrough()),
112
+ },
113
+ async ({ query, limit = 3 }) => {
114
+ const pattern = `%${query}%`;
115
+ try {
116
+ const result = await pgClient.query(
117
+ `SELECT * FROM faqs
118
+ WHERE question ILIKE $1 OR answer ILIKE $1
119
+ LIMIT $2`,
120
+ [pattern, limit]
121
+ );
122
+ return result.rows.length > 0
123
+ ? result.rows
124
+ : [{ question: query, answer: `No FAQ found for "${query}". Answer based on your knowledge.` }];
125
+ } catch (err) {
126
+ console.error('lookupFAQ tool error:', err.message);
127
+ return [{ error: 'FAQ lookup temporarily unavailable.' }];
128
+ }
129
+ }
130
+ );
131
+
132
+ // ---------------------------------------------------------------------------
133
+ // Dynamic tools (per-request, userId bound in closure)
134
+ // ---------------------------------------------------------------------------
135
+
136
+ /**
137
+ * Factory — call once per request to get user-scoped tools.
138
+ * @param {string} userId
139
+ * @returns {import('genkit').ToolAction[]}
140
+ */
141
+ function createUserTools(userId) {
142
+ /**
143
+ * Save a hair goal the user has explicitly stated.
144
+ * Appended to user_preferences.hair_priorities JSONB so it persists across sessions.
145
+ */
146
+ const saveHairGoal = ai.dynamicTool(
147
+ {
148
+ name: 'saveHairGoal',
149
+ description:
150
+ 'Save a specific hair goal the user has clearly stated they want to achieve. ' +
151
+ 'Call this when the user explicitly says something like "my goal is...", "I want to...", ' +
152
+ '"I\'m trying to...", or "I\'d love to achieve...". ' +
153
+ 'Do NOT call this for vague preferences — only concrete, stated goals. ' +
154
+ 'Confirm to the user that their goal has been saved.',
155
+ inputSchema: z.object({
156
+ goal: z.string().describe(
157
+ 'The specific hair goal stated by the user, in their own words ' +
158
+ '(e.g. "grow hair 4 inches by December", "retain moisture without frizz", "transition to natural")'
159
+ ),
160
+ }),
161
+ outputSchema: z.object({ saved: z.boolean(), goal: z.string() }),
162
+ },
163
+ async ({ goal }) => {
164
+ try {
165
+ await pgClient.query(
166
+ `INSERT INTO user_preferences (user_id, hair_priorities)
167
+ VALUES ($1, $2::jsonb)
168
+ ON CONFLICT (user_id) DO UPDATE SET
169
+ hair_priorities = COALESCE(user_preferences.hair_priorities, '{}') || $2::jsonb`,
170
+ [
171
+ userId,
172
+ JSON.stringify({
173
+ goals: goal,
174
+ savedAt: new Date().toISOString(),
175
+ }),
176
+ ]
177
+ );
178
+ console.log(`🎯 Hair goal saved for ${userId}: ${goal}`);
179
+ return { saved: true, goal };
180
+ } catch (err) {
181
+ console.error('saveHairGoal tool error:', err.message);
182
+ return { saved: false, goal };
183
+ }
184
+ }
185
+ );
186
+
187
+ /**
188
+ * Fetch the user's current hair profile from the DB.
189
+ * Useful mid-conversation when the cached system prompt may be stale (>24h old)
190
+ * and the user's hair type or concerns have been updated since.
191
+ */
192
+ const getUserHairProfile = ai.dynamicTool(
193
+ {
194
+ name: 'getUserHairProfile',
195
+ description:
196
+ 'Retrieve the current hair profile on file for this user: hair type, texture, concerns, goals, ' +
197
+ 'porosity, and other stored attributes. ' +
198
+ 'Call this when you need to personalize a recommendation but are unsure whether your context ' +
199
+ 'has the most up-to-date profile, or when the user references a characteristic you don\'t have.',
200
+ inputSchema: z.object({}),
201
+ outputSchema: z.record(z.unknown()),
202
+ },
203
+ async () => {
204
+ try {
205
+ const result = await pgClient.query(
206
+ `SELECT hair_type, hair_concerns, hair_texture, porosity, elasticity, density,
207
+ curl_pattern, hair_goals, hair_concerns, damage_level_self_reported,
208
+ confidence_level_inferred, experimenting_interest
209
+ FROM users WHERE user_id = $1`,
210
+ [userId]
211
+ );
212
+ if (result.rows.length === 0) {
213
+ return { message: 'No hair profile on file yet for this user.' };
214
+ }
215
+ // Strip null fields to keep context concise
216
+ const profile = Object.fromEntries(
217
+ Object.entries(result.rows[0]).filter(([, v]) => v !== null && v !== undefined)
218
+ );
219
+ return Object.keys(profile).length > 0
220
+ ? profile
221
+ : { message: 'Hair profile exists but no attributes recorded yet.' };
222
+ } catch (err) {
223
+ console.error('getUserHairProfile tool error:', err.message);
224
+ return { error: 'Hair profile temporarily unavailable.' };
225
+ }
226
+ }
227
+ );
228
+
229
+ return [saveHairGoal, getUserHairProfile];
230
+ }
231
+
232
+ // ---------------------------------------------------------------------------
233
+
234
+ const globalTools = [searchServices, lookupFAQ];
235
+
236
+ module.exports = { globalTools, createUserTools };
package/src/ai.js CHANGED
@@ -8,7 +8,7 @@ const ai = genkit({
8
8
  plugins: [googleAI({
9
9
  apiKey: config.genkitApiKey,
10
10
  })],
11
- model: googleAI.model('gemini-3-flash-preview'),
11
+ model: googleAI.model('gemini-3.7-flash'),
12
12
  });
13
13
 
14
14
  module.exports = ai;
@@ -28,7 +28,11 @@ class CacheManager {
28
28
  }
29
29
 
30
30
  /**
31
- * Check cache validity across all storage layers
31
+ * Fast check for a valid Gemini cache across all storage layers (PG → Redis → memory).
32
+ * Call this before constructPrompt() — if hasValidCache is true, skip prompt construction.
33
+ *
34
+ * @param {string} userId
35
+ * @returns {Promise<{hasValidCache, cacheName, needsRefresh, reason, source}>}
32
36
  */
33
37
  async checkCacheValidity(userId) {
34
38
  console.log('🔍 Quick cache check for user:', userId);
@@ -112,7 +116,13 @@ class CacheManager {
112
116
  }
113
117
 
114
118
  /**
115
- * Get or create cache
119
+ * Returns the existing valid Gemini cache name for this user, or creates a new one
120
+ * from the provided system prompt string. Saves to all available storage layers.
121
+ *
122
+ * @param {string} userId
123
+ * @param {string} systemPrompt - Full system instruction text to cache
124
+ * @returns {Promise<{cacheName: string|null, isNewCache: boolean, error?: string}>}
125
+ * cacheName is null if cache creation failed — caller should fall back to sending full prompt
116
126
  */
117
127
  async getOrCreateCache(userId, systemPrompt) {
118
128
  console.log('🔍 ===== CACHE MANAGER: GET OR CREATE =====');
@@ -138,7 +148,7 @@ class CacheManager {
138
148
  // Create new cache
139
149
  console.log('🆕 Creating new Gemini cache...');
140
150
  const cacheStartTime = performance.now();
141
- const modelName = "gemini-3-flash-preview";
151
+ const modelName = "gemini-3.7-flash";
142
152
 
143
153
  const cache = await cacheAi.caches.create({
144
154
  model: modelName,
@@ -314,10 +324,10 @@ class CacheManager {
314
324
 
315
325
  try {
316
326
  const key = `gemini_cache:${userId}`;
317
- await redisClient.setex(
318
- key,
319
- this.CACHE_TTL_SECONDS,
320
- JSON.stringify(cacheData)
327
+ await redisClient.set(
328
+ key,
329
+ JSON.stringify(cacheData),
330
+ { EX: this.CACHE_TTL_SECONDS }
321
331
  );
322
332
  console.log('✅ Redis save successful');
323
333
  return true;
package/src/config.js CHANGED
@@ -27,7 +27,7 @@ module.exports = {
27
27
  },
28
28
  genkitApiKey: process.env.GENKIT_API_KEY,
29
29
  openaiApiKey: process.env.OPENAI_API_KEY,
30
- defaultModel: 'gemini15Flash',
31
- experimentalModel: 'gemini20FlashExp',
30
+ defaultModel: 'gemini-3.7-flash',
31
+ experimentalModel: 'gemini-3.7-flash',
32
32
  temperature: 1.1,
33
33
  };
@@ -28,7 +28,7 @@ const generateConversationSummary = async (chatHistory) => {
28
28
  }
29
29
  const summaryPrompt = `Summarize the following conversation, getting all important information, especially the user's hair details, name, and location: ${JSON.stringify(chatHistory)}`;
30
30
  const { text } = await ai.chat({
31
- model: googleAI.model('gemini-2.5-flash'),
31
+ model: googleAI.model('gemini-3.6-flash'),
32
32
  system: summaryPrompt,
33
33
  });
34
34
  return text;
@@ -0,0 +1,230 @@
1
+ // conversationDriver.js
2
+ // Analyzes conversation state and generates per-message behavioral directives
3
+ // that guide how the AI should engage — not just what to say, but what to DO.
4
+ //
5
+ // Injected as a small context block via buildMessageContext(), alongside
6
+ // the persona block from personaClassifier.
7
+ //
8
+ // The goal: make the AI a conversation driver, not just a responder.
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Stage definitions — mirrors the 8-stage framework in system.js
12
+ // ---------------------------------------------------------------------------
13
+
14
+ const STAGES = {
15
+ 1: { name: 'something-new', label: 'Something New' },
16
+ 2: { name: 'rocking-this', label: 'Rocking This' },
17
+ 3: { name: 'experimenting', label: 'Experimenting' },
18
+ 4: { name: 'boredom', label: 'Boredom' },
19
+ 5: { name: 'desperation', label: 'Desperation' },
20
+ 6: { name: 'damage-protection', label: 'Damage/Protection' },
21
+ 7: { name: 'repair-decision', label: 'Repair/Decision' },
22
+ 8: { name: 'hair-nirvana', label: 'Hair Nirvana' },
23
+ };
24
+
25
+ // Keywords used to infer stage from recent messages (subset of system.js table)
26
+ const STAGE_KEYWORDS = [
27
+ { stage: 8, words: ['nirvana', 'love my routine', 'healthy and happy', 'low maintenance', 'carefree', 'content', 'maintaining my hair'] },
28
+ { stage: 7, words: ['repairing', 'restoring', 'healing', 'new direction', 'making progress', 'growing out'] },
29
+ { stage: 6, words: ['damage', 'heat damage', 'protective style', 'split ends', 'overprocessed', 'breakage', 'weak hair'] },
30
+ { stage: 5, words: ['desperate', 'overwhelmed', 'hate my hair', "don't know what to do", 'frustrated', 'give up', 'stressed', 'hopeless'] },
31
+ { stage: 4, words: ['bored', 'need a change', 'stale', 'want something different', 'tired of my hair', 'ready for something new'] },
32
+ { stage: 3, words: ['experimenting', 'trying new things', 'curious about', 'exploring', 'want to try', 'different styles'] },
33
+ { stage: 2, words: ['love my hair', 'happy with it', 'feeling good', "i'm rocking", 'in my element', 'confident'] },
34
+ { stage: 1, words: ['big chop', 'new color', 'first time', 'starting over', 'fresh start', 'just cut my hair', 'new style'] },
35
+ ];
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Analysis helpers
39
+ // ---------------------------------------------------------------------------
40
+
41
+ /**
42
+ * Infer the most likely stage from recent user messages.
43
+ * Scans last 4 user messages — recent context is more reliable than old.
44
+ * Returns the highest-priority (lowest number wins ties) matched stage, or null.
45
+ *
46
+ * @param {Array<{role:string, content:string}>} chatHistory
47
+ * @returns {number|null}
48
+ */
49
+ function inferStage(chatHistory) {
50
+ const recentUserMessages = chatHistory
51
+ .filter(m => m.role === 'user')
52
+ .slice(-4)
53
+ .map(m => (m.content || '').toLowerCase());
54
+
55
+ const combined = recentUserMessages.join(' ');
56
+ if (!combined) return null;
57
+
58
+ for (const { stage, words } of STAGE_KEYWORDS) {
59
+ if (words.some(w => combined.includes(w))) return stage;
60
+ }
61
+ return null;
62
+ }
63
+
64
+ /**
65
+ * Count how many of the last N AI turns ended with a question.
66
+ * Rough but effective: any turn containing '?' is considered a question turn.
67
+ *
68
+ * @param {Array<{role:string, content:string}>} chatHistory
69
+ * @param {number} lookback How many recent AI turns to check (default 3)
70
+ * @returns {{ total: number, withQuestion: number, lastTurnHadQuestion: boolean }}
71
+ */
72
+ function analyzeAIQuestions(chatHistory, lookback = 3) {
73
+ const aiTurns = chatHistory
74
+ .filter(m => m.role === 'model' || m.role === 'assistant')
75
+ .slice(-lookback);
76
+
77
+ const withQuestion = aiTurns.filter(m => (m.content || '').includes('?')).length;
78
+ const lastTurnHadQuestion = aiTurns.length > 0
79
+ ? (aiTurns[aiTurns.length - 1].content || '').includes('?')
80
+ : false;
81
+
82
+ return { total: aiTurns.length, withQuestion, lastTurnHadQuestion };
83
+ }
84
+
85
+ /**
86
+ * Returns rough conversation depth category.
87
+ *
88
+ * @param {Array} chatHistory
89
+ * @returns {'opening'|'exploring'|'deepening'|'action-ready'}
90
+ */
91
+ function getDepthCategory(chatHistory) {
92
+ const userTurns = chatHistory.filter(m => m.role === 'user').length;
93
+ if (userTurns <= 1) return 'opening';
94
+ if (userTurns <= 3) return 'exploring';
95
+ if (userTurns <= 7) return 'deepening';
96
+ return 'action-ready';
97
+ }
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // Directive builder
101
+ // ---------------------------------------------------------------------------
102
+
103
+ /**
104
+ * Generates a short behavioral directive string based on conversation state.
105
+ * Returns empty string when the conversation needs no additional steering.
106
+ *
107
+ * @param {object} opts
108
+ * @param {number|null} opts.stage - Inferred stage number (1–8) or null
109
+ * @param {string} opts.depth - 'opening'|'exploring'|'deepening'|'action-ready'
110
+ * @param {object} opts.questionStats - From analyzeAIQuestions()
111
+ * @param {string|null} opts.personaType - From personaClassifier (optional)
112
+ * @param {string|null} opts.trendSummary - Short trend names string from trendManager (optional)
113
+ * @returns {string}
114
+ */
115
+ function buildDrivingDirective({ stage, depth, questionStats, personaType, trendSummary = null }) {
116
+ const parts = [];
117
+
118
+ // Opening: always gather profile info if we don't have a stage read yet
119
+ if (depth === 'opening' && stage === null) {
120
+ parts.push('ask one open question to understand their hair type or primary concern — do not assume');
121
+ }
122
+
123
+ // Exploring: AI should be asking questions to understand, not just informing
124
+ else if (depth === 'exploring') {
125
+ if (!questionStats.lastTurnHadQuestion) {
126
+ parts.push('end your response with a specific follow-up question to learn more about their routine or goals');
127
+ }
128
+ if (stage !== null) {
129
+ parts.push(`stage context: ${STAGES[stage]?.label} — tailor your depth and tone accordingly`);
130
+ }
131
+ }
132
+
133
+ // Deepening: AI has enough info — guide toward insight or next step
134
+ else if (depth === 'deepening') {
135
+ if (questionStats.withQuestion === 0) {
136
+ // Stagnating — use a trend as a new angle if available
137
+ if (trendSummary) {
138
+ parts.push(`conversation needs new momentum — introduce a trending topic (${trendSummary}) as a natural next angle`);
139
+ } else {
140
+ parts.push('the conversation needs forward momentum — ask something that surfaces a new dimension (routine, history, environment, goals)');
141
+ }
142
+ } else if (stage === 3 && trendSummary) {
143
+ // Experimenting + trend: give them concrete options to try
144
+ parts.push(`user is experimenting — reference a trending technique (${trendSummary}) as one option worth exploring`);
145
+ } else if (stage === 4 && trendSummary) {
146
+ // Boredom + trend: use trend as the inspiration for change
147
+ parts.push(`user is bored — use a current trend (${trendSummary}) as inspiration to spark excitement about a change`);
148
+ } else if (stage !== null && stage >= 5 && stage <= 7) {
149
+ // High-need stages — gently surface Myavana solutions
150
+ parts.push('user is in a high-need phase — after addressing their question, briefly introduce a relevant Myavana service as a natural next step (do not push)');
151
+ }
152
+ }
153
+
154
+ // Action-ready: longer conversation — move toward a decision or commitment
155
+ else if (depth === 'action-ready') {
156
+ if (stage !== null && stage >= 5 && stage <= 7) {
157
+ if (trendSummary) {
158
+ parts.push(`well-developed conversation — guide toward a concrete action; a trending approach (${trendSummary}) could be the bridge to a Myavana service`);
159
+ } else {
160
+ parts.push('this is a well-developed conversation — guide the user toward a concrete action: a Myavana service, a specific routine step, or a commitment to try something');
161
+ }
162
+ } else if (stage === 8) {
163
+ parts.push('user is thriving — celebrate their progress and offer maintenance tips; keep the energy positive and light');
164
+ } else if (trendSummary) {
165
+ parts.push(`offer a specific personalized recommendation; consider whether a trending technique (${trendSummary}) is relevant to their situation`);
166
+ } else {
167
+ parts.push('this conversation is deep enough to offer a specific personalized recommendation; do not stay generic');
168
+ }
169
+ }
170
+
171
+ // Cross-cutting: overwhelmed users should not be pushed toward action too soon
172
+ if (personaType === 'overwhelmed' && parts.some(p => p.includes('action') || p.includes('service') || p.includes('trending'))) {
173
+ const idx = parts.findIndex(p => p.includes('action') || p.includes('service') || p.includes('trending'));
174
+ if (idx !== -1) {
175
+ parts[idx] = 'when the moment feels right, gently surface one option — but lead with empathy first';
176
+ }
177
+ }
178
+
179
+ if (parts.length === 0) return '';
180
+ return `[Conversation driver: ${parts.join('; ')}]\n`;
181
+ }
182
+
183
+ // ---------------------------------------------------------------------------
184
+ // Public API
185
+ // ---------------------------------------------------------------------------
186
+
187
+ /**
188
+ * Analyses chat history and returns a driving directive string to inject
189
+ * into the message context. Returns empty string when nothing needed.
190
+ *
191
+ * @param {Array<{role:string, content:string}>} chatHistory
192
+ * @param {string|null} personaType - From personaClassifier (optional)
193
+ * @param {string|null} trendSummary - Trend names from trendManager (optional)
194
+ * @returns {string}
195
+ */
196
+ function getConversationDirective(chatHistory, personaType = null, trendSummary = null) {
197
+ if (!chatHistory || chatHistory.length === 0) {
198
+ return '[Conversation driver: this is the very first message — warmly open with a question about their hair type or what brings them in today]\n';
199
+ }
200
+
201
+ const stage = inferStage(chatHistory);
202
+ const depth = getDepthCategory(chatHistory);
203
+ const questionStats = analyzeAIQuestions(chatHistory);
204
+
205
+ return buildDrivingDirective({ stage, depth, questionStats, personaType, trendSummary });
206
+ }
207
+
208
+ /**
209
+ * Convenience — extracts just the trend summary string from a trend context block.
210
+ * e.g. "[Trending in hair care: scalp detoxing; rice water]\n" → "scalp detoxing; rice water"
211
+ *
212
+ * @param {string} trendContext - Full trend block from trendManager.getTrendContext()
213
+ * @returns {string|null}
214
+ */
215
+ function extractTrendSummary(trendContext) {
216
+ if (!trendContext) return null;
217
+ const match = trendContext.match(/\[Trending in hair care: (.+?)\]/);
218
+ return match ? match[1] : null;
219
+ }
220
+
221
+ module.exports = {
222
+ getConversationDirective,
223
+ extractTrendSummary,
224
+ // Exported for testing
225
+ inferStage,
226
+ analyzeAIQuestions,
227
+ getDepthCategory,
228
+ buildDrivingDirective,
229
+ STAGES,
230
+ };