myavana-bot-test-core 2.3.3 → 2.4.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.
- package/index.js +17 -0
- package/migrations/001_add_analytics_tables.sql +4 -2
- package/migrations/002_add_advanced_features_tables.sql +15 -8
- package/migrations/008_platform_identity.sql +18 -0
- package/migrations/run_migrations.sql +6 -2
- package/package.json +1 -4
- package/scripts/runMigrations.js +68 -0
- package/src/__tests__/cacheManager.test.js +7 -10
- package/src/__tests__/hairJourneyService.test.js +162 -0
- package/src/agentMemory.js +2 -4
- package/src/agentTools.js +200 -1
- package/src/ai/aiCore.js +458 -0
- package/src/ai/index.js +8 -0
- package/src/ai.js +2 -12
- package/src/cacheManager.js +1 -12
- package/src/chatProtocol/index.js +14 -0
- package/src/chatProtocol/ndjson.js +144 -0
- package/src/chatProtocol/responseParser.js +384 -0
- package/src/chatProtocol/types.js +66 -0
- package/src/config.js +3 -0
- package/src/context/promptBudgetManager.js +134 -0
- package/src/conversation.js +37 -8
- package/src/database.js +38 -8
- package/src/experience/experienceContextService.js +100 -0
- package/src/hairJourney/hairJourneyProvider.js +98 -0
- package/src/hairJourney/hairJourneyService.js +320 -0
- package/src/hairJourney/wordPressHairJourneyProvider.js +343 -0
- package/src/messageContextBuilder.js +13 -1
- package/src/postResponseProcessing.js +2 -4
- package/src/trendManager.js +1 -2
- package/src/unifiedChatHandler.js +4 -5
- package/src/user.js +1 -0
- package/src/utils.js +3 -1
package/src/agentTools.js
CHANGED
|
@@ -226,7 +226,206 @@ function createUserTools(userId) {
|
|
|
226
226
|
}
|
|
227
227
|
);
|
|
228
228
|
|
|
229
|
-
|
|
229
|
+
/**
|
|
230
|
+
* Hair Journey Integration Tools
|
|
231
|
+
*/
|
|
232
|
+
const { HairJourneyService } = require('./hairJourney/hairJourneyService');
|
|
233
|
+
const hairJourneyService = new HairJourneyService();
|
|
234
|
+
|
|
235
|
+
const getHairGoals = ai.dynamicTool(
|
|
236
|
+
{
|
|
237
|
+
name: 'getHairGoals',
|
|
238
|
+
description:
|
|
239
|
+
'Retrieve the user\'s active hair goals, target metrics (e.g. length in inches), target dates, and percentage progress from the Hair Journey system. ' +
|
|
240
|
+
'Call this when the user asks about their goals, asks how close they are to a milestone, or when reviewing progress.',
|
|
241
|
+
inputSchema: z.object({}),
|
|
242
|
+
outputSchema: z.record(z.unknown()),
|
|
243
|
+
},
|
|
244
|
+
async () => {
|
|
245
|
+
try {
|
|
246
|
+
const { result, blocks } = await hairJourneyService.getGoalsWithBlocks(userId);
|
|
247
|
+
return {
|
|
248
|
+
goals: result.goals || [],
|
|
249
|
+
overview: result.overview || {},
|
|
250
|
+
blocks
|
|
251
|
+
};
|
|
252
|
+
} catch (err) {
|
|
253
|
+
console.error('getHairGoals tool error:', err.message);
|
|
254
|
+
return { error: 'Goals temporarily unavailable.' };
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
const getTodayJourney = ai.dynamicTool(
|
|
260
|
+
{
|
|
261
|
+
name: 'getTodayJourney',
|
|
262
|
+
description:
|
|
263
|
+
'Retrieve today\'s hair care checklist, routine steps for the day, and current care streak. ' +
|
|
264
|
+
'Call this when the user asks what they should do today, asks about their wash day steps, or asks for today\'s checklist.',
|
|
265
|
+
inputSchema: z.object({}),
|
|
266
|
+
outputSchema: z.record(z.unknown()),
|
|
267
|
+
},
|
|
268
|
+
async () => {
|
|
269
|
+
try {
|
|
270
|
+
const { result, blocks } = await hairJourneyService.getTodayWithBlocks(userId);
|
|
271
|
+
return {
|
|
272
|
+
date: result.date,
|
|
273
|
+
checklist: result.checklist || [],
|
|
274
|
+
streakDays: result.streakDays,
|
|
275
|
+
blocks
|
|
276
|
+
};
|
|
277
|
+
} catch (err) {
|
|
278
|
+
console.error('getTodayJourney tool error:', err.message);
|
|
279
|
+
return { error: 'Today hub temporarily unavailable.' };
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
const getHairRoutines = ai.dynamicTool(
|
|
285
|
+
{
|
|
286
|
+
name: 'getHairRoutines',
|
|
287
|
+
description:
|
|
288
|
+
'Retrieve the user\'s defined hair care routines (e.g. wash day, nightly maintenance, L.O.C. method steps) and product cabinet. ' +
|
|
289
|
+
'Call this when the user asks about their routine schedule or regimen.',
|
|
290
|
+
inputSchema: z.object({}),
|
|
291
|
+
outputSchema: z.record(z.unknown()),
|
|
292
|
+
},
|
|
293
|
+
async () => {
|
|
294
|
+
try {
|
|
295
|
+
const { result, blocks } = await hairJourneyService.getRoutinesWithBlocks(userId);
|
|
296
|
+
return {
|
|
297
|
+
routines: result.routines || [],
|
|
298
|
+
cabinet: result.cabinet || [],
|
|
299
|
+
blocks
|
|
300
|
+
};
|
|
301
|
+
} catch (err) {
|
|
302
|
+
console.error('getHairRoutines tool error:', err.message);
|
|
303
|
+
return { error: 'Routines temporarily unavailable.' };
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
const completeRoutineStep = ai.dynamicTool(
|
|
309
|
+
{
|
|
310
|
+
name: 'completeRoutineStep',
|
|
311
|
+
description:
|
|
312
|
+
'Mark a specific routine step as completed for today (e.g. deep condition with heat, hydration mist). ' +
|
|
313
|
+
'Call this when the user says they completed, did, or finished a routine step.',
|
|
314
|
+
inputSchema: z.object({
|
|
315
|
+
stepId: z.string().describe('ID or name of the routine step completed'),
|
|
316
|
+
}),
|
|
317
|
+
outputSchema: z.record(z.unknown()),
|
|
318
|
+
},
|
|
319
|
+
async ({ stepId }) => {
|
|
320
|
+
try {
|
|
321
|
+
const res = await hairJourneyService.completeStep(userId, stepId);
|
|
322
|
+
return { success: true, stepId, message: 'Step marked completed.' };
|
|
323
|
+
} catch (err) {
|
|
324
|
+
console.error('completeRoutineStep tool error:', err.message);
|
|
325
|
+
return { success: false, error: err.message };
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
const updateHairGoal = ai.dynamicTool(
|
|
331
|
+
{
|
|
332
|
+
name: 'updateHairGoal',
|
|
333
|
+
description:
|
|
334
|
+
'Update a hair goal\'s target metric (e.g. "5 Inches") or target date (e.g. "November 2026"). ' +
|
|
335
|
+
'Call this when the user explicitly requests to change or adjust a goal.',
|
|
336
|
+
inputSchema: z.object({
|
|
337
|
+
goalId: z.string().describe('The ID of the goal to update'),
|
|
338
|
+
targetMetric: z.string().optional().describe('New target metric, e.g. "5 Inches"'),
|
|
339
|
+
targetDate: z.string().optional().describe('New target date, e.g. "November 2026"'),
|
|
340
|
+
}),
|
|
341
|
+
outputSchema: z.record(z.unknown()),
|
|
342
|
+
},
|
|
343
|
+
async ({ goalId, targetMetric, targetDate }) => {
|
|
344
|
+
try {
|
|
345
|
+
const res = await hairJourneyService.updateGoal(userId, goalId, { targetMetric, targetDate });
|
|
346
|
+
return { success: true, goalId, targetMetric, targetDate, message: 'Goal updated successfully.' };
|
|
347
|
+
} catch (err) {
|
|
348
|
+
console.error('updateHairGoal tool error:', err.message);
|
|
349
|
+
return { success: false, error: err.message };
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
const createJournalEntry = ai.dynamicTool(
|
|
355
|
+
{
|
|
356
|
+
name: 'createJournalEntry',
|
|
357
|
+
description:
|
|
358
|
+
'Log a hair journey journal entry or progress check-in (e.g. wash day results, moisture level, notes, photo milestone). ' +
|
|
359
|
+
'Call this when the user wants to log, write, or record something in their journal or hair diary, or after snapping a progress photo.',
|
|
360
|
+
inputSchema: z.object({
|
|
361
|
+
title: z.string().describe('Short title for the journal entry, e.g. "Sunday Wash Day" or "Twist Out Check-in"'),
|
|
362
|
+
notes: z.string().describe('Observations or notes about the hair, products used, scalp feel, or results'),
|
|
363
|
+
entryType: z.enum(['wash_day', 'quick_checkin', 'length_check', 'milestone']).optional().describe('Type of entry (default quick_checkin)'),
|
|
364
|
+
moistureLevel: z.number().int().min(1).max(5).optional().describe('Rating from 1 (very dry) to 5 (optimally hydrated)'),
|
|
365
|
+
scalpState: z.string().optional().describe('Scalp condition, e.g. "Calm, clean" or "Slight flaking"'),
|
|
366
|
+
}),
|
|
367
|
+
outputSchema: z.record(z.unknown()),
|
|
368
|
+
},
|
|
369
|
+
async ({ title, notes, entryType = 'quick_checkin', moistureLevel = 4, scalpState = 'Healthy' }) => {
|
|
370
|
+
try {
|
|
371
|
+
const { result, blocks } = await hairJourneyService.createJournalEntryWithBlock(userId, {
|
|
372
|
+
title,
|
|
373
|
+
notes,
|
|
374
|
+
entryType,
|
|
375
|
+
moistureLevel,
|
|
376
|
+
scalpState,
|
|
377
|
+
date: new Date().toISOString().split('T')[0]
|
|
378
|
+
});
|
|
379
|
+
return {
|
|
380
|
+
success: true,
|
|
381
|
+
entry: result,
|
|
382
|
+
blocks,
|
|
383
|
+
message: 'Journal entry recorded successfully.'
|
|
384
|
+
};
|
|
385
|
+
} catch (err) {
|
|
386
|
+
console.error('createJournalEntry tool error:', err.message);
|
|
387
|
+
return { success: false, error: err.message };
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
);
|
|
391
|
+
|
|
392
|
+
const getJournalEntries = ai.dynamicTool(
|
|
393
|
+
{
|
|
394
|
+
name: 'getJournalEntries',
|
|
395
|
+
description:
|
|
396
|
+
'Retrieve recent journal entries and logged progress notes from the user\'s hair journey. ' +
|
|
397
|
+
'Call this when the user asks what they logged recently, asks about past wash days, or reviews their hair history.',
|
|
398
|
+
inputSchema: z.object({
|
|
399
|
+
perPage: z.number().int().min(1).max(10).optional().describe('Number of entries to retrieve (default 3)'),
|
|
400
|
+
}),
|
|
401
|
+
outputSchema: z.record(z.unknown()),
|
|
402
|
+
},
|
|
403
|
+
async ({ perPage = 3 } = {}) => {
|
|
404
|
+
try {
|
|
405
|
+
const { result, blocks } = await hairJourneyService.getJournalEntriesWithBlocks(userId, { perPage });
|
|
406
|
+
return {
|
|
407
|
+
items: result.items || [],
|
|
408
|
+
total: result.total || 0,
|
|
409
|
+
blocks
|
|
410
|
+
};
|
|
411
|
+
} catch (err) {
|
|
412
|
+
console.error('getJournalEntries tool error:', err.message);
|
|
413
|
+
return { error: 'Journal entries temporarily unavailable.' };
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
);
|
|
417
|
+
|
|
418
|
+
return [
|
|
419
|
+
saveHairGoal,
|
|
420
|
+
getUserHairProfile,
|
|
421
|
+
getHairGoals,
|
|
422
|
+
getTodayJourney,
|
|
423
|
+
getHairRoutines,
|
|
424
|
+
completeRoutineStep,
|
|
425
|
+
updateHairGoal,
|
|
426
|
+
createJournalEntry,
|
|
427
|
+
getJournalEntries,
|
|
428
|
+
];
|
|
230
429
|
}
|
|
231
430
|
|
|
232
431
|
// ---------------------------------------------------------------------------
|
package/src/ai/aiCore.js
ADDED
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
// packages/core/src/ai/aiCore.js
|
|
2
|
+
// Clean, owned Google Gen AI SDK integration for Mya.
|
|
3
|
+
// Replaces Genkit, solves the 'role: function' bug, and eliminates workspace hoisting issues.
|
|
4
|
+
|
|
5
|
+
const path = require('path');
|
|
6
|
+
require('dotenv').config();
|
|
7
|
+
require('dotenv').config({ path: path.resolve(__dirname, '../../.env') });
|
|
8
|
+
require('dotenv').config({ path: path.resolve(__dirname, '../../../myavana/.env') });
|
|
9
|
+
|
|
10
|
+
const { GoogleGenAI } = require('@google/genai');
|
|
11
|
+
const config = require('../config');
|
|
12
|
+
|
|
13
|
+
function zodToGeminiSchema(schema) {
|
|
14
|
+
if (!schema) return { type: 'OBJECT', properties: {} };
|
|
15
|
+
if (schema.type && (schema.properties || schema.items)) return schema;
|
|
16
|
+
|
|
17
|
+
let def = schema._def;
|
|
18
|
+
if (!def) return { type: 'STRING' };
|
|
19
|
+
|
|
20
|
+
if (def.typeName === 'ZodOptional' || def.typeName === 'ZodNullable') {
|
|
21
|
+
def = def.innerType._def;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const description = schema.description || def.description;
|
|
25
|
+
|
|
26
|
+
switch (def.typeName) {
|
|
27
|
+
case 'ZodString':
|
|
28
|
+
return { type: 'STRING', description };
|
|
29
|
+
case 'ZodNumber':
|
|
30
|
+
return { type: 'NUMBER', description };
|
|
31
|
+
case 'ZodBoolean':
|
|
32
|
+
return { type: 'BOOLEAN', description };
|
|
33
|
+
case 'ZodEnum':
|
|
34
|
+
return { type: 'STRING', enum: def.values, description };
|
|
35
|
+
case 'ZodArray':
|
|
36
|
+
return { type: 'ARRAY', items: zodToGeminiSchema(def.type), description };
|
|
37
|
+
case 'ZodObject': {
|
|
38
|
+
const properties = {};
|
|
39
|
+
const required = [];
|
|
40
|
+
const shape = typeof def.shape === 'function' ? def.shape() : def.shape;
|
|
41
|
+
if (shape) {
|
|
42
|
+
for (const [key, value] of Object.entries(shape)) {
|
|
43
|
+
properties[key] = zodToGeminiSchema(value);
|
|
44
|
+
if (value._def?.typeName !== 'ZodOptional') {
|
|
45
|
+
required.push(key);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
type: 'OBJECT',
|
|
51
|
+
properties,
|
|
52
|
+
required: required.length > 0 ? required : undefined,
|
|
53
|
+
description,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
case 'ZodRecord':
|
|
57
|
+
return { type: 'OBJECT', description };
|
|
58
|
+
default:
|
|
59
|
+
return { type: 'STRING', description };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function normalizeModelName(model) {
|
|
64
|
+
if (!model) return 'gemini-3.7-flash';
|
|
65
|
+
if (typeof model === 'string') {
|
|
66
|
+
return model.replace(/^models\//, '');
|
|
67
|
+
}
|
|
68
|
+
if (typeof model === 'object' && model.name) {
|
|
69
|
+
return model.name.replace(/^models\//, '');
|
|
70
|
+
}
|
|
71
|
+
return 'gemini-3.7-flash';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
class AiCore {
|
|
75
|
+
constructor(opts = {}) {
|
|
76
|
+
this.opts = opts;
|
|
77
|
+
this._client = null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
get client() {
|
|
81
|
+
if (!this._client) {
|
|
82
|
+
const apiKey = this.opts.apiKey || config.genkitApiKey || process.env.GEMINI_API_KEY || process.env.GENKIT_API_KEY;
|
|
83
|
+
if (!apiKey) {
|
|
84
|
+
console.warn('⚠️ Warning: No Gemini API key provided to AiCore.');
|
|
85
|
+
}
|
|
86
|
+
this._client = new GoogleGenAI({ apiKey });
|
|
87
|
+
}
|
|
88
|
+
return this._client;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
defineTool(toolConfig, fn) {
|
|
92
|
+
return {
|
|
93
|
+
name: toolConfig.name,
|
|
94
|
+
description: toolConfig.description,
|
|
95
|
+
parameters: zodToGeminiSchema(toolConfig.inputSchema),
|
|
96
|
+
execute: fn,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
dynamicTool(toolConfig, fn) {
|
|
101
|
+
return this.defineTool(toolConfig, fn);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async embed(opts) {
|
|
105
|
+
const text = typeof opts === 'string' ? opts : opts.content || opts.text;
|
|
106
|
+
const model = opts.embedder || 'gemini-embedding-001';
|
|
107
|
+
const outputDimensionality = opts.outputDimensionality || 768;
|
|
108
|
+
const res = await this.client.models.embedContent({
|
|
109
|
+
model: normalizeModelName(model),
|
|
110
|
+
contents: text,
|
|
111
|
+
config: { outputDimensionality },
|
|
112
|
+
});
|
|
113
|
+
const values = res.embeddings?.[0]?.values || res.embedding?.values || [];
|
|
114
|
+
return [{ embedding: values }];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async generate(opts) {
|
|
118
|
+
const model = normalizeModelName(opts.model || 'gemini-3.7-flash');
|
|
119
|
+
const contents = opts.prompt || opts.contents;
|
|
120
|
+
const temperature = opts.config?.temperature;
|
|
121
|
+
|
|
122
|
+
const reqConfig = {};
|
|
123
|
+
if (temperature !== undefined) reqConfig.temperature = temperature;
|
|
124
|
+
|
|
125
|
+
if (opts.output?.format === 'json' || opts.output?.schema) {
|
|
126
|
+
reqConfig.responseMimeType = 'application/json';
|
|
127
|
+
if (opts.output.schema) {
|
|
128
|
+
reqConfig.responseSchema = zodToGeminiSchema(opts.output.schema);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const res = await this.client.models.generateContent({
|
|
133
|
+
model,
|
|
134
|
+
contents,
|
|
135
|
+
config: reqConfig,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
let output = res.text;
|
|
139
|
+
if (opts.output?.format === 'json' || reqConfig.responseMimeType === 'application/json') {
|
|
140
|
+
try {
|
|
141
|
+
output = JSON.parse(res.text);
|
|
142
|
+
} catch {
|
|
143
|
+
// keep as string if parse fails
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return { text: res.text, output };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Transcribes spoken audio into text using gemini-3.5-transcribe with domain biasing
|
|
151
|
+
* and automatic fallback to gemini-2.5-flash.
|
|
152
|
+
*
|
|
153
|
+
* @param {object} opts
|
|
154
|
+
* @param {Buffer|string} opts.audioData - Raw Buffer or base64 encoded audio string
|
|
155
|
+
* @param {string} [opts.mimeType='audio/webm']
|
|
156
|
+
* @param {string[]} [opts.customVocabulary] - Custom domain biasing terms
|
|
157
|
+
* @param {string} [opts.model='gemini-3.5-transcribe']
|
|
158
|
+
* @returns {Promise<{text: string, modelUsed: string}>}
|
|
159
|
+
*/
|
|
160
|
+
async transcribeAudio({ audioData, mimeType = 'audio/webm', customVocabulary = [], model = 'gemini-3.5-transcribe' }) {
|
|
161
|
+
const base64Data = Buffer.isBuffer(audioData) ? audioData.toString('base64') : audioData;
|
|
162
|
+
const defaultVocab = [
|
|
163
|
+
'MYAVANA', 'Mya', 'HairID', 'HHCP', 'porosity', 'elasticity', 'density',
|
|
164
|
+
'Type 4A', 'Type 4B', 'Type 4C', 'Type 3A', 'Type 3B', 'Type 3C',
|
|
165
|
+
'coils', 'curls', 'pre-poo', 'clarifying cleanse', 'steam hydration',
|
|
166
|
+
'L.O.C. method', 'knotless', 'boho braids', 'length retention', 'scalp care', 'Candace'
|
|
167
|
+
];
|
|
168
|
+
const vocabulary = Array.from(new Set([...defaultVocab, ...customVocabulary]));
|
|
169
|
+
|
|
170
|
+
const primaryModel = normalizeModelName(model);
|
|
171
|
+
try {
|
|
172
|
+
console.log(`🎙️ Transcribing audio with ${primaryModel}... (${base64Data.length} b64 chars, ${mimeType})`);
|
|
173
|
+
const contents = [
|
|
174
|
+
{
|
|
175
|
+
inlineData: {
|
|
176
|
+
mimeType,
|
|
177
|
+
data: base64Data,
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
];
|
|
181
|
+
|
|
182
|
+
if (vocabulary.length > 0) {
|
|
183
|
+
contents.push({
|
|
184
|
+
text: `Speech recognition biasing vocabulary: ${vocabulary.join(', ')}. Return only the exact transcription without meta-commentary.`
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const res = await this.client.models.generateContent({
|
|
189
|
+
model: primaryModel,
|
|
190
|
+
contents,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const text = (res.text || '').trim();
|
|
194
|
+
if (!text) {
|
|
195
|
+
throw new Error(`Empty transcription returned by ${primaryModel}`);
|
|
196
|
+
}
|
|
197
|
+
console.log(`✅ Transcription success (${primaryModel}): "${text}"`);
|
|
198
|
+
return { text, modelUsed: primaryModel };
|
|
199
|
+
} catch (err) {
|
|
200
|
+
console.warn(`⚠️ Primary transcription failed with ${primaryModel}: ${err.message}. Falling back to gemini-2.5-flash...`);
|
|
201
|
+
const fallbackModel = 'gemini-2.5-flash';
|
|
202
|
+
const fallbackRes = await this.client.models.generateContent({
|
|
203
|
+
model: fallbackModel,
|
|
204
|
+
contents: [
|
|
205
|
+
{
|
|
206
|
+
inlineData: {
|
|
207
|
+
mimeType,
|
|
208
|
+
data: base64Data,
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
text: 'Transcribe this spoken audio accurately. Return only the transcribed text.'
|
|
213
|
+
}
|
|
214
|
+
],
|
|
215
|
+
});
|
|
216
|
+
const text = (fallbackRes.text || '').trim();
|
|
217
|
+
console.log(`✅ Fallback transcription success (${fallbackModel}): "${text}"`);
|
|
218
|
+
return { text, modelUsed: fallbackModel };
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Connect to Gemini Live API for bidirectional real-time audio/multimodal sessions
|
|
224
|
+
*
|
|
225
|
+
* @param {object} opts
|
|
226
|
+
* @param {string} [opts.model='gemini-3.5-live-translate-preview']
|
|
227
|
+
* @param {object} opts.callbacks - { onopen, onmessage, onerror, onclose }
|
|
228
|
+
* @param {string} [opts.systemInstruction]
|
|
229
|
+
* @param {Array} [opts.tools]
|
|
230
|
+
*/
|
|
231
|
+
async createLiveSession(opts = {}) {
|
|
232
|
+
const model = normalizeModelName(opts.model || 'gemini-3.5-live-translate-preview');
|
|
233
|
+
return await this.client.live.connect({
|
|
234
|
+
model,
|
|
235
|
+
callbacks: opts.callbacks,
|
|
236
|
+
config: {
|
|
237
|
+
systemInstruction: opts.systemInstruction,
|
|
238
|
+
generationConfig: {
|
|
239
|
+
responseModalities: ['AUDIO', 'TEXT'],
|
|
240
|
+
speechConfig: {
|
|
241
|
+
voiceConfig: {
|
|
242
|
+
prebuiltVoiceConfig: {
|
|
243
|
+
voiceName: opts.voiceName || 'Aoede'
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
chat(opts = {}) {
|
|
253
|
+
const model = normalizeModelName(opts.model || 'gemini-3.7-flash');
|
|
254
|
+
const tools = Array.isArray(opts.tools) ? opts.tools : [];
|
|
255
|
+
const temperature = opts.config?.temperature ?? 1.0;
|
|
256
|
+
|
|
257
|
+
const toolMap = new Map();
|
|
258
|
+
const functionDeclarations = [];
|
|
259
|
+
|
|
260
|
+
for (const t of tools) {
|
|
261
|
+
if (t && t.name && typeof t.execute === 'function') {
|
|
262
|
+
toolMap.set(t.name, t);
|
|
263
|
+
functionDeclarations.push({
|
|
264
|
+
name: t.name,
|
|
265
|
+
description: t.description || '',
|
|
266
|
+
parameters: t.parameters || { type: 'OBJECT', properties: {} },
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const chatConfig = {
|
|
272
|
+
temperature,
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
if (opts.cache) {
|
|
276
|
+
chatConfig.cachedContent = opts.cache;
|
|
277
|
+
} else if (opts.system) {
|
|
278
|
+
chatConfig.systemInstruction = opts.system;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (functionDeclarations.length > 0) {
|
|
282
|
+
chatConfig.tools = [{ functionDeclarations }];
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const genaiChat = this.client.chats.create({
|
|
286
|
+
model,
|
|
287
|
+
config: chatConfig,
|
|
288
|
+
history: opts.history,
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
genaiChat,
|
|
293
|
+
send: async (message) => {
|
|
294
|
+
let res = await genaiChat.sendMessage({ message });
|
|
295
|
+
const toolCalls = [];
|
|
296
|
+
let loopCount = 0;
|
|
297
|
+
const maxLoops = 5;
|
|
298
|
+
|
|
299
|
+
while (res.functionCalls && res.functionCalls.length > 0 && loopCount < maxLoops) {
|
|
300
|
+
loopCount++;
|
|
301
|
+
const functionResponses = [];
|
|
302
|
+
|
|
303
|
+
for (const call of res.functionCalls) {
|
|
304
|
+
const tool = toolMap.get(call.name);
|
|
305
|
+
toolCalls.push({ name: call.name, args: call.args });
|
|
306
|
+
let toolResult = null;
|
|
307
|
+
|
|
308
|
+
if (tool) {
|
|
309
|
+
try {
|
|
310
|
+
toolResult = await tool.execute(call.args);
|
|
311
|
+
} catch (err) {
|
|
312
|
+
console.error(`❌ Tool ${call.name} execution error:`, err.message);
|
|
313
|
+
toolResult = { error: err.message };
|
|
314
|
+
}
|
|
315
|
+
} else {
|
|
316
|
+
console.warn(`⚠️ Tool ${call.name} requested by model but not found.`);
|
|
317
|
+
toolResult = { error: `Tool ${call.name} not implemented.` };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
functionResponses.push({
|
|
321
|
+
functionResponse: {
|
|
322
|
+
name: call.name,
|
|
323
|
+
response: typeof toolResult === 'object' && toolResult !== null ? toolResult : { result: toolResult },
|
|
324
|
+
},
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
res = await genaiChat.sendMessage({ message: functionResponses });
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const text = res.text || '';
|
|
332
|
+
let output = text;
|
|
333
|
+
|
|
334
|
+
// Try parsing JSON if output looks like JSON (e.g. Kommunicate payload or JSON blocks)
|
|
335
|
+
const trimmed = text.trim();
|
|
336
|
+
if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
|
|
337
|
+
try {
|
|
338
|
+
output = JSON.parse(trimmed);
|
|
339
|
+
} catch {
|
|
340
|
+
output = text;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return { output, text, toolCalls };
|
|
345
|
+
},
|
|
346
|
+
|
|
347
|
+
sendStream: (message) => {
|
|
348
|
+
let textAccumulator = '';
|
|
349
|
+
const toolCalls = [];
|
|
350
|
+
const toolBlocks = [];
|
|
351
|
+
let resolveResponse;
|
|
352
|
+
const responsePromise = new Promise((resolve) => {
|
|
353
|
+
resolveResponse = resolve;
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
const streamGenerator = async function* () {
|
|
357
|
+
try {
|
|
358
|
+
let currentMessage = message;
|
|
359
|
+
let loopCount = 0;
|
|
360
|
+
const maxLoops = 5;
|
|
361
|
+
|
|
362
|
+
while (loopCount < maxLoops) {
|
|
363
|
+
loopCount++;
|
|
364
|
+
const pendingFunctionCalls = [];
|
|
365
|
+
const responseStream = await genaiChat.sendMessageStream({ message: currentMessage });
|
|
366
|
+
|
|
367
|
+
for await (const chunk of responseStream) {
|
|
368
|
+
if (chunk.functionCalls && chunk.functionCalls.length > 0) {
|
|
369
|
+
pendingFunctionCalls.push(...chunk.functionCalls);
|
|
370
|
+
}
|
|
371
|
+
if (chunk.text) {
|
|
372
|
+
textAccumulator += chunk.text;
|
|
373
|
+
yield { text: chunk.text };
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (pendingFunctionCalls.length === 0) {
|
|
378
|
+
break;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const functionResponses = [];
|
|
382
|
+
for (const call of pendingFunctionCalls) {
|
|
383
|
+
const tool = toolMap.get(call.name);
|
|
384
|
+
toolCalls.push({ name: call.name, args: call.args });
|
|
385
|
+
let toolResult = null;
|
|
386
|
+
|
|
387
|
+
if (tool) {
|
|
388
|
+
try {
|
|
389
|
+
toolResult = await tool.execute(call.args);
|
|
390
|
+
} catch (err) {
|
|
391
|
+
console.error(`❌ Tool ${call.name} execution error:`, err.message);
|
|
392
|
+
toolResult = { error: err.message };
|
|
393
|
+
}
|
|
394
|
+
} else {
|
|
395
|
+
console.warn(`⚠️ Tool ${call.name} requested by model but not found.`);
|
|
396
|
+
toolResult = { error: `Tool ${call.name} not implemented.` };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (toolResult && toolResult.blocks && Array.isArray(toolResult.blocks)) {
|
|
400
|
+
toolBlocks.push(...toolResult.blocks);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
functionResponses.push({
|
|
404
|
+
functionResponse: {
|
|
405
|
+
name: call.name,
|
|
406
|
+
response: typeof toolResult === 'object' && toolResult !== null ? toolResult : { result: toolResult },
|
|
407
|
+
},
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
currentMessage = functionResponses;
|
|
412
|
+
}
|
|
413
|
+
} finally {
|
|
414
|
+
resolveResponse({ text: textAccumulator, output: textAccumulator, toolCalls, toolBlocks });
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
const stream = streamGenerator();
|
|
419
|
+
|
|
420
|
+
return {
|
|
421
|
+
stream,
|
|
422
|
+
response: responsePromise,
|
|
423
|
+
};
|
|
424
|
+
},
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async loadSession(userId, { store } = {}) {
|
|
429
|
+
let sessionData = null;
|
|
430
|
+
if (store && typeof store.get === 'function') {
|
|
431
|
+
sessionData = await store.get(userId);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return {
|
|
435
|
+
userId,
|
|
436
|
+
chat: (opts = {}) => {
|
|
437
|
+
return this.chat({
|
|
438
|
+
...opts,
|
|
439
|
+
history: sessionData?.history,
|
|
440
|
+
});
|
|
441
|
+
},
|
|
442
|
+
save: async (data) => {
|
|
443
|
+
if (store && typeof store.save === 'function') {
|
|
444
|
+
await store.save(userId, data);
|
|
445
|
+
}
|
|
446
|
+
},
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const aiCore = new AiCore();
|
|
452
|
+
|
|
453
|
+
module.exports = {
|
|
454
|
+
AiCore,
|
|
455
|
+
aiCore,
|
|
456
|
+
zodToGeminiSchema,
|
|
457
|
+
normalizeModelName,
|
|
458
|
+
};
|
package/src/ai/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// packages/core/src/ai/index.js
|
|
2
|
+
const { AiCore, aiCore, zodToGeminiSchema, normalizeModelName } = require('./aiCore');
|
|
3
|
+
|
|
4
|
+
module.exports = aiCore;
|
|
5
|
+
module.exports.aiCore = aiCore;
|
|
6
|
+
module.exports.AiCore = AiCore;
|
|
7
|
+
module.exports.zodToGeminiSchema = zodToGeminiSchema;
|
|
8
|
+
module.exports.normalizeModelName = normalizeModelName;
|
package/src/ai.js
CHANGED
|
@@ -1,14 +1,4 @@
|
|
|
1
1
|
// ai.js
|
|
2
|
-
|
|
3
|
-
const
|
|
4
|
-
|
|
5
|
-
const config = require('./config');
|
|
6
|
-
|
|
7
|
-
const ai = genkit({
|
|
8
|
-
plugins: [googleAI({
|
|
9
|
-
apiKey: config.genkitApiKey,
|
|
10
|
-
})],
|
|
11
|
-
model: googleAI.model('gemini-3.7-flash'),
|
|
12
|
-
});
|
|
13
|
-
|
|
2
|
+
// Backwards-compatible entrypoint exporting owned AiCore singleton.
|
|
3
|
+
const ai = require('./ai/index');
|
|
14
4
|
module.exports = ai;
|