ak-gemini 1.1.13 → 2.0.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,352 @@
1
+ /**
2
+ * @fileoverview Pure utility functions for JSON extraction and recovery.
3
+ * Used by Transformer and Message classes to parse AI model responses.
4
+ */
5
+
6
+ import log from './logger.js';
7
+
8
+ /**
9
+ * Checks if a JavaScript value is a JSON-serializable object or array.
10
+ * @param {*} data - The value to check
11
+ * @returns {boolean}
12
+ */
13
+ export function isJSON(data) {
14
+ try {
15
+ const attempt = JSON.stringify(data);
16
+ if (attempt?.startsWith('{') || attempt?.startsWith('[')) {
17
+ if (attempt?.endsWith('}') || attempt?.endsWith(']')) {
18
+ return true;
19
+ }
20
+ }
21
+ return false;
22
+ } catch (e) {
23
+ return false;
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Checks if a string is valid JSON that parses to an object or array.
29
+ * @param {string} string - The string to check
30
+ * @returns {boolean}
31
+ */
32
+ export function isJSONStr(string) {
33
+ if (typeof string !== 'string') return false;
34
+ try {
35
+ const result = JSON.parse(string);
36
+ const type = Object.prototype.toString.call(result);
37
+ return type === '[object Object]' || type === '[object Array]';
38
+ } catch (err) {
39
+ return false;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Attempts to recover truncated JSON by progressively removing characters from the end
45
+ * until valid JSON is found or recovery fails.
46
+ * @param {string} text - The potentially truncated JSON string
47
+ * @param {number} [maxAttempts=100] - Maximum number of characters to remove
48
+ * @returns {Object|null} - Parsed JSON object or null if recovery fails
49
+ */
50
+ export function attemptJSONRecovery(text, maxAttempts = 100) {
51
+ if (!text || typeof text !== 'string') return null;
52
+
53
+ // First, try parsing as-is
54
+ try {
55
+ return JSON.parse(text);
56
+ } catch (e) {
57
+ // Continue with recovery
58
+ }
59
+
60
+ let workingText = text.trim();
61
+
62
+ // First attempt: try to close unclosed structures without removing characters
63
+ let braces = 0;
64
+ let brackets = 0;
65
+ let inString = false;
66
+ let escapeNext = false;
67
+
68
+ for (let j = 0; j < workingText.length; j++) {
69
+ const char = workingText[j];
70
+
71
+ if (escapeNext) {
72
+ escapeNext = false;
73
+ continue;
74
+ }
75
+
76
+ if (char === '\\') {
77
+ escapeNext = true;
78
+ continue;
79
+ }
80
+
81
+ if (char === '"') {
82
+ inString = !inString;
83
+ continue;
84
+ }
85
+
86
+ if (!inString) {
87
+ if (char === '{') braces++;
88
+ else if (char === '}') braces--;
89
+ else if (char === '[') brackets++;
90
+ else if (char === ']') brackets--;
91
+ }
92
+ }
93
+
94
+ // Try to fix by just adding closing characters
95
+ if ((braces > 0 || brackets > 0 || inString) && workingText.length > 2) {
96
+ let fixedText = workingText;
97
+
98
+ if (inString) {
99
+ fixedText += '"';
100
+ }
101
+
102
+ while (braces > 0) {
103
+ fixedText += '}';
104
+ braces--;
105
+ }
106
+ while (brackets > 0) {
107
+ fixedText += ']';
108
+ brackets--;
109
+ }
110
+
111
+ try {
112
+ const result = JSON.parse(fixedText);
113
+ if (log.level !== 'silent') {
114
+ log.warn(`JSON response appears truncated (possibly hit maxOutputTokens limit). Recovered by adding closing characters.`);
115
+ }
116
+ return result;
117
+ } catch (e) {
118
+ // Simple fix didn't work, continue with more aggressive recovery
119
+ }
120
+ }
121
+
122
+ // Second attempt: progressively remove characters from the end
123
+ for (let i = 0; i < maxAttempts && workingText.length > 2; i++) {
124
+ workingText = workingText.slice(0, -1);
125
+
126
+ let braces = 0;
127
+ let brackets = 0;
128
+ let inString = false;
129
+ let escapeNext = false;
130
+
131
+ for (let j = 0; j < workingText.length; j++) {
132
+ const char = workingText[j];
133
+
134
+ if (escapeNext) {
135
+ escapeNext = false;
136
+ continue;
137
+ }
138
+
139
+ if (char === '\\') {
140
+ escapeNext = true;
141
+ continue;
142
+ }
143
+
144
+ if (char === '"') {
145
+ inString = !inString;
146
+ continue;
147
+ }
148
+
149
+ if (!inString) {
150
+ if (char === '{') braces++;
151
+ else if (char === '}') braces--;
152
+ else if (char === '[') brackets++;
153
+ else if (char === ']') brackets--;
154
+ }
155
+ }
156
+
157
+ // If we have balanced braces/brackets, try parsing
158
+ if (braces === 0 && brackets === 0 && !inString) {
159
+ try {
160
+ const result = JSON.parse(workingText);
161
+ if (log.level !== 'silent') {
162
+ log.warn(`JSON response appears truncated (possibly hit maxOutputTokens limit). Recovered by removing ${i + 1} characters from the end.`);
163
+ }
164
+ return result;
165
+ } catch (e) {
166
+ // Continue trying
167
+ }
168
+ }
169
+
170
+ // After a few attempts, try adding closing characters
171
+ if (i > 5) {
172
+ let fixedText = workingText;
173
+
174
+ if (inString) {
175
+ fixedText += '"';
176
+ }
177
+
178
+ while (braces > 0) {
179
+ fixedText += '}';
180
+ braces--;
181
+ }
182
+ while (brackets > 0) {
183
+ fixedText += ']';
184
+ brackets--;
185
+ }
186
+
187
+ try {
188
+ const result = JSON.parse(fixedText);
189
+ if (log.level !== 'silent') {
190
+ log.warn(`JSON response appears truncated (possibly hit maxOutputTokens limit). Recovered by adding closing characters.`);
191
+ }
192
+ return result;
193
+ } catch (e) {
194
+ // Recovery failed, continue trying
195
+ }
196
+ }
197
+ }
198
+
199
+ return null;
200
+ }
201
+
202
+ /**
203
+ * Extracts a complete JSON structure from text starting at a given position
204
+ * using bracket/brace matching.
205
+ * @param {string} text - The text containing JSON
206
+ * @param {number} startPos - Position of the opening bracket/brace
207
+ * @returns {string|null} - The complete JSON structure or null
208
+ */
209
+ function extractCompleteStructure(text, startPos) {
210
+ const startChar = text[startPos];
211
+ const endChar = startChar === '{' ? '}' : ']';
212
+ let depth = 0;
213
+ let inString = false;
214
+ let escaped = false;
215
+
216
+ for (let i = startPos; i < text.length; i++) {
217
+ const char = text[i];
218
+
219
+ if (escaped) {
220
+ escaped = false;
221
+ continue;
222
+ }
223
+
224
+ if (char === '\\' && inString) {
225
+ escaped = true;
226
+ continue;
227
+ }
228
+
229
+ if (char === '"' && !escaped) {
230
+ inString = !inString;
231
+ continue;
232
+ }
233
+
234
+ if (!inString) {
235
+ if (char === startChar) {
236
+ depth++;
237
+ } else if (char === endChar) {
238
+ depth--;
239
+ if (depth === 0) {
240
+ return text.substring(startPos, i + 1);
241
+ }
242
+ }
243
+ }
244
+ }
245
+
246
+ return null;
247
+ }
248
+
249
+ /**
250
+ * Finds all complete JSON structures (objects and arrays) in text.
251
+ * @param {string} text - The text to search
252
+ * @returns {string[]} - Array of JSON structure strings
253
+ */
254
+ function findCompleteJSONStructures(text) {
255
+ const results = [];
256
+ const startChars = ['{', '['];
257
+
258
+ for (let i = 0; i < text.length; i++) {
259
+ if (startChars.includes(text[i])) {
260
+ const extracted = extractCompleteStructure(text, i);
261
+ if (extracted) {
262
+ results.push(extracted);
263
+ }
264
+ }
265
+ }
266
+
267
+ return results;
268
+ }
269
+
270
+ /**
271
+ * Extracts valid JSON from model response text using multiple strategies.
272
+ * @param {string} text - The model response text
273
+ * @returns {Object} - Parsed JSON object
274
+ * @throws {Error} If no valid JSON can be extracted
275
+ */
276
+ export function extractJSON(text) {
277
+ if (!text || typeof text !== 'string') {
278
+ throw new Error('No text provided for JSON extraction');
279
+ }
280
+
281
+ // Strategy 1: Try parsing the entire response as JSON
282
+ if (isJSONStr(text.trim())) {
283
+ return JSON.parse(text.trim());
284
+ }
285
+
286
+ // Strategy 2: Look for JSON code blocks (```json...``` or ```...```)
287
+ const codeBlockPatterns = [
288
+ /```json\s*\n?([\s\S]*?)\n?\s*```/gi,
289
+ /```\s*\n?([\s\S]*?)\n?\s*```/gi
290
+ ];
291
+
292
+ for (const pattern of codeBlockPatterns) {
293
+ const matches = text.match(pattern);
294
+ if (matches) {
295
+ for (const match of matches) {
296
+ const jsonContent = match.replace(/```json\s*\n?/gi, '').replace(/```\s*\n?/gi, '').trim();
297
+ if (isJSONStr(jsonContent)) {
298
+ return JSON.parse(jsonContent);
299
+ }
300
+ }
301
+ }
302
+ }
303
+
304
+ // Strategy 3: Look for JSON objects/arrays using bracket matching
305
+ const jsonPatterns = [
306
+ /\{[\s\S]*\}/g,
307
+ /\[[\s\S]*\]/g
308
+ ];
309
+
310
+ for (const pattern of jsonPatterns) {
311
+ const matches = text.match(pattern);
312
+ if (matches) {
313
+ for (const match of matches) {
314
+ const candidate = match.trim();
315
+ if (isJSONStr(candidate)) {
316
+ return JSON.parse(candidate);
317
+ }
318
+ }
319
+ }
320
+ }
321
+
322
+ // Strategy 4: Advanced bracket matching for nested structures
323
+ const advancedExtract = findCompleteJSONStructures(text);
324
+ if (advancedExtract.length > 0) {
325
+ for (const candidate of advancedExtract) {
326
+ if (isJSONStr(candidate)) {
327
+ return JSON.parse(candidate);
328
+ }
329
+ }
330
+ }
331
+
332
+ // Strategy 5: Clean up common formatting issues and retry
333
+ const cleanedText = text
334
+ .replace(/^\s*Sure,?\s*here\s+is\s+your?\s+.*?[:\n]/gi, '')
335
+ .replace(/^\s*Here\s+is\s+the\s+.*?[:\n]/gi, '')
336
+ .replace(/^\s*The\s+.*?is\s*[:\n]/gi, '')
337
+ .replace(/\/\*[\s\S]*?\*\//g, '')
338
+ .replace(/\/\/.*$/gm, '')
339
+ .trim();
340
+
341
+ if (isJSONStr(cleanedText)) {
342
+ return JSON.parse(cleanedText);
343
+ }
344
+
345
+ // Strategy 6: Last resort - attempt recovery for potentially truncated JSON
346
+ const recoveredJSON = attemptJSONRecovery(text);
347
+ if (recoveredJSON !== null) {
348
+ return recoveredJSON;
349
+ }
350
+
351
+ throw new Error(`Could not extract valid JSON from model response. Response preview: ${text.substring(0, 200)}...`);
352
+ }
package/message.js ADDED
@@ -0,0 +1,170 @@
1
+ /**
2
+ * @fileoverview Message class — stateless one-off messages to AI.
3
+ * Uses generateContent() instead of chat sessions. No conversation history.
4
+ */
5
+
6
+ import BaseGemini from './base.js';
7
+ import { extractJSON } from './json-helpers.js';
8
+ import log from './logger.js';
9
+
10
+ /**
11
+ * @typedef {import('./types').MessageOptions} MessageOptions
12
+ * @typedef {import('./types').MessageResponse} MessageResponse
13
+ */
14
+
15
+ /**
16
+ * Stateless one-off messages to AI.
17
+ * Each send() call is independent — no conversation history is maintained.
18
+ * Uses generateContent() directly instead of chat sessions.
19
+ *
20
+ * Optionally returns structured data when responseSchema or
21
+ * responseMimeType: 'application/json' is configured.
22
+ *
23
+ * @example
24
+ * ```javascript
25
+ * import { Message } from 'ak-gemini';
26
+ *
27
+ * // Simple text response
28
+ * const msg = new Message({
29
+ * systemPrompt: 'You are a helpful assistant.'
30
+ * });
31
+ * const r = await msg.send('What is the capital of France?');
32
+ * console.log(r.text); // "The capital of France is Paris."
33
+ *
34
+ * // Structured JSON response
35
+ * const jsonMsg = new Message({
36
+ * systemPrompt: 'Extract entities from text.',
37
+ * responseMimeType: 'application/json'
38
+ * });
39
+ * const r2 = await jsonMsg.send('Alice works at Acme Corp in New York.');
40
+ * console.log(r2.data); // { entities: [...] }
41
+ * ```
42
+ */
43
+ class Message extends BaseGemini {
44
+ /**
45
+ * @param {MessageOptions} [options={}]
46
+ */
47
+ constructor(options = {}) {
48
+ super(options);
49
+
50
+ // ── Structured output config ──
51
+ if (options.responseSchema) {
52
+ this.chatConfig.responseSchema = options.responseSchema;
53
+ }
54
+ if (options.responseMimeType) {
55
+ this.chatConfig.responseMimeType = options.responseMimeType;
56
+ }
57
+
58
+ this._isStructured = !!(options.responseSchema || options.responseMimeType === 'application/json');
59
+
60
+ log.debug(`Message created (structured=${this._isStructured})`);
61
+ }
62
+
63
+ /**
64
+ * Initialize the Message client.
65
+ * Override: creates genAIClient only, NO chat session (stateless).
66
+ * @param {boolean} [force=false]
67
+ * @returns {Promise<void>}
68
+ */
69
+ async init(force = false) {
70
+ if (this._initialized && !force) return;
71
+
72
+ log.debug(`Initializing ${this.constructor.name} with model: ${this.modelName}...`);
73
+
74
+ try {
75
+ await this.genAIClient.models.list();
76
+ log.debug(`${this.constructor.name}: API connection successful.`);
77
+ } catch (e) {
78
+ throw new Error(`${this.constructor.name} initialization failed: ${e.message}`);
79
+ }
80
+
81
+ this._initialized = true;
82
+ log.debug(`${this.constructor.name}: Initialized (stateless mode).`);
83
+ }
84
+
85
+ /**
86
+ * Send a stateless message and get a response.
87
+ * Each call is independent — no history is maintained.
88
+ *
89
+ * @param {Object|string} payload - The message or data to send
90
+ * @param {Object} [opts={}] - Per-message options
91
+ * @param {Record<string, string>} [opts.labels] - Per-message billing labels
92
+ * @returns {Promise<MessageResponse>} Response with text, optional data, and usage
93
+ */
94
+ async send(payload, opts = {}) {
95
+ if (!this._initialized) await this.init();
96
+
97
+ const payloadStr = typeof payload === 'string'
98
+ ? payload
99
+ : JSON.stringify(payload, null, 2);
100
+
101
+ const contents = [{ role: 'user', parts: [{ text: payloadStr }] }];
102
+
103
+ const mergedLabels = { ...this.labels, ...(opts.labels || {}) };
104
+
105
+ const result = await this.genAIClient.models.generateContent({
106
+ model: this.modelName,
107
+ contents: contents,
108
+ config: {
109
+ ...this.chatConfig,
110
+ ...(this.vertexai && Object.keys(mergedLabels).length > 0 && { labels: mergedLabels })
111
+ }
112
+ });
113
+
114
+ this._captureMetadata(result);
115
+
116
+ this._cumulativeUsage = {
117
+ promptTokens: this.lastResponseMetadata.promptTokens,
118
+ responseTokens: this.lastResponseMetadata.responseTokens,
119
+ totalTokens: this.lastResponseMetadata.totalTokens,
120
+ attempts: 1
121
+ };
122
+
123
+ if (result.usageMetadata && log.level !== 'silent') {
124
+ log.debug(`Message response: model=${result.modelVersion || 'unknown'}, tokens=${result.usageMetadata.totalTokenCount}`);
125
+ }
126
+
127
+ const text = result.text || '';
128
+ const response = {
129
+ text,
130
+ usage: this.getLastUsage()
131
+ };
132
+
133
+ // Parse structured data if configured
134
+ if (this._isStructured) {
135
+ try {
136
+ response.data = extractJSON(text);
137
+ } catch (e) {
138
+ log.warn(`Could not parse structured response: ${e.message}`);
139
+ response.data = null;
140
+ }
141
+ }
142
+
143
+ return response;
144
+ }
145
+
146
+ // ── No-ops for stateless class ──
147
+
148
+ /** @returns {Array} Always returns empty array (stateless). */
149
+ getHistory() { return []; }
150
+
151
+ /** No-op (stateless). */
152
+ async clearHistory() { }
153
+
154
+ /** Not supported on Message (stateless). */
155
+ async seed() {
156
+ log.warn("Message is stateless — seed() has no effect. Use Transformer or Chat for few-shot learning.");
157
+ return [];
158
+ }
159
+
160
+ /**
161
+ * Not supported on Message (stateless).
162
+ * @param {any} [_nextPayload]
163
+ * @returns {Promise<{inputTokens: number}>}
164
+ */
165
+ async estimate(_nextPayload) {
166
+ throw new Error("Message is stateless — use estimate() on Chat or Transformer which have conversation context.");
167
+ }
168
+ }
169
+
170
+ export default Message;
package/package.json CHANGED
@@ -1,12 +1,19 @@
1
1
  {
2
2
  "name": "ak-gemini",
3
3
  "author": "ak@mixpanel.com",
4
- "description": "AK's Generative AI Helper for doing... transforms",
5
- "version": "1.1.13",
4
+ "description": "AK's Generative AI Helper for doing... everything",
5
+ "version": "2.0.0",
6
6
  "main": "index.js",
7
7
  "files": [
8
8
  "index.js",
9
9
  "index.cjs",
10
+ "base.js",
11
+ "transformer.js",
12
+ "chat.js",
13
+ "message.js",
14
+ "tool-agent.js",
15
+ "code-agent.js",
16
+ "json-helpers.js",
10
17
  "types.d.ts",
11
18
  "logger.js"
12
19
  ],
@@ -38,7 +45,7 @@
38
45
  "update-deps": "npx npm-check-updates -u && npm install",
39
46
  "prune": "rm -rf tmp/*",
40
47
  "test": "node --no-warnings --experimental-vm-modules node_modules/jest/bin/jest.js",
41
- "build:cjs": "esbuild index.js --bundle --platform=node --format=cjs --outfile=index.cjs --external:@google/genai --external:ak-tools --external:dotenv --external:pino-pretty --external:pino",
48
+ "build:cjs": "esbuild index.js --bundle --platform=node --format=cjs --outfile=index.cjs --external:@google/genai --external:dotenv --external:pino-pretty --external:pino",
42
49
  "coverage": "node --no-warnings --experimental-vm-modules node_modules/jest/bin/jest.js --coverage",
43
50
  "typecheck": "tsc --noEmit",
44
51
  "update:gemini": "npm install @google/genai@latest"
@@ -47,21 +54,23 @@
47
54
  "keywords": [
48
55
  "gemini",
49
56
  "ai wrapper",
50
- "json transform"
57
+ "json transform",
58
+ "chat",
59
+ "agent",
60
+ "tool agent"
51
61
  ],
52
62
  "license": "ISC",
53
63
  "dependencies": {
54
- "@google/genai": "^1.34.0",
55
- "ak-tools": "^1.1.12",
56
- "dotenv": "^16.5.0",
57
- "pino": "^9.7.0",
58
- "pino-pretty": "^13.0.0"
64
+ "@google/genai": "^1.44.0",
65
+ "dotenv": "^17.3.1",
66
+ "pino": "^10.3.1",
67
+ "pino-pretty": "^13.1.3"
59
68
  },
60
69
  "devDependencies": {
61
- "@types/jest": "^29.5.14",
62
- "esbuild": "^0.25.11",
63
- "jest": "^29.7.0",
64
- "nodemon": "^3.1.10",
65
- "typescript": "^5.9.2"
70
+ "@types/jest": "^30.0.0",
71
+ "esbuild": "^0.27.4",
72
+ "jest": "^30.3.0",
73
+ "nodemon": "^3.1.14",
74
+ "typescript": "^5.9.3"
66
75
  }
67
76
  }