@bobfrankston/gcards 0.1.10 → 0.1.11

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/gcards.ts CHANGED
@@ -14,11 +14,22 @@ import type { GooglePerson, GoogleConnectionsResponse } from './glib/types.ts';
14
14
  import { GCARDS_GUID_KEY, extractGuids } from './glib/gctypes.ts';
15
15
  import type { ContactIndex, IndexEntry, DeletedEntry, DeleteQueue, DeleteQueueEntry, PushStatus, PendingChange, UserPaths } from './glib/gctypes.ts';
16
16
  import { DATA_DIR, CREDENTIALS_FILE, loadConfig, saveConfig, getUserPaths, ensureUserDir, loadIndex, normalizeUser, getAllUsers, resolveUser, FileLogger } from './glib/gutils.ts';
17
+ import { explainApiError, isAIAvailable } from './glib/aihelper.ts';
17
18
 
18
19
  const PEOPLE_API_BASE = 'https://people.googleapis.com/v1';
19
20
  const CONTACTS_SCOPE_READ = 'https://www.googleapis.com/auth/contacts.readonly';
20
21
  const CONTACTS_SCOPE_WRITE = 'https://www.googleapis.com/auth/contacts';
21
22
 
23
+ /** Custom error that includes the payload for AI analysis */
24
+ class ApiError extends Error {
25
+ payload?: unknown;
26
+ constructor(message: string, payload?: unknown) {
27
+ super(message);
28
+ this.name = 'ApiError';
29
+ this.payload = payload;
30
+ }
31
+ }
32
+
22
33
  function ts(): string {
23
34
  const now = new Date();
24
35
  return `[${now.toTimeString().slice(0, 8)}]`;
@@ -698,7 +709,7 @@ async function updateContactOnGoogle(person: GooglePerson, retryCount = 0, token
698
709
  }
699
710
 
700
711
  if (!response.ok) {
701
- throw new Error(`${response.status} ${await response.text()}`);
712
+ throw new ApiError(`${response.status} ${await response.text()}`, person);
702
713
  }
703
714
  }
704
715
 
@@ -778,7 +789,7 @@ async function createContactOnGoogle(person: GooglePerson, retryCount = 0, token
778
789
  }
779
790
 
780
791
  if (!response.ok) {
781
- throw new Error(`${response.status} ${await response.text()}`);
792
+ throw new ApiError(`${response.status} ${await response.text()}`, person);
782
793
  }
783
794
 
784
795
  return await response.json();
@@ -1023,6 +1034,26 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
1023
1034
  console.log(` ERROR: ${error.message}`);
1024
1035
  problems.push(errorMsg);
1025
1036
  errorCount++;
1037
+
1038
+ // Get AI explanation if available and error is from API
1039
+ if (error instanceof ApiError && error.payload && isAIAvailable()) {
1040
+ try {
1041
+ const explanation = await explainApiError(error.message, error.payload);
1042
+ if (explanation) {
1043
+ console.log(`\n AI Analysis:`);
1044
+ if (explanation.explanation) {
1045
+ console.log(` Problem: ${explanation.explanation}`);
1046
+ }
1047
+ if (explanation.suggestedFix) {
1048
+ console.log(` Fix: ${explanation.suggestedFix}`);
1049
+ }
1050
+ if (explanation.correctedJson) {
1051
+ console.log(` Corrected JSON:\n${explanation.correctedJson.split('\n').map(l => ' ' + l).join('\n')}`);
1052
+ }
1053
+ problems.push(` AI: ${explanation.explanation || ''} Fix: ${explanation.suggestedFix || ''}`);
1054
+ }
1055
+ } catch { /* ignore AI errors */ }
1056
+ }
1026
1057
  }
1027
1058
  }
1028
1059
 
@@ -0,0 +1,196 @@
1
+ /**
2
+ * AI-powered error explanation helper for Google People API errors
3
+ * Uses Anthropic Claude (primary) or OpenAI (fallback)
4
+ */
5
+
6
+ import fs from 'fs';
7
+ import path from 'path';
8
+
9
+ // Load keys from standard locations (AppData for Windows, ~/.config for Unix)
10
+ function loadEnvFile(): void {
11
+ const home = process.env.HOME || process.env.USERPROFILE || '';
12
+ const appData = process.env.APPDATA || path.join(home, '.config');
13
+
14
+ const locations = [
15
+ path.join(appData, 'gcards', 'keys.env'), // Standard app config location
16
+ path.join(home, '.gcards', 'keys.env'), // Unix dotfile style
17
+ path.join(process.cwd(), 'keys.env'), // Local override (dev only)
18
+ path.join(process.cwd(), '.env'),
19
+ ];
20
+
21
+ for (const loc of locations) {
22
+ if (fs.existsSync(loc)) {
23
+ const content = fs.readFileSync(loc, 'utf-8');
24
+ for (const line of content.split(/\r?\n/)) {
25
+ const match = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
26
+ if (match && !process.env[match[1]]) {
27
+ process.env[match[1]] = match[2].trim().replace(/^["']|["']$/g, '');
28
+ }
29
+ }
30
+ break;
31
+ }
32
+ }
33
+ }
34
+
35
+ loadEnvFile();
36
+
37
+ export interface AIErrorExplanation {
38
+ explanation: string;
39
+ suggestedFix: string;
40
+ correctedJson?: string;
41
+ }
42
+
43
+ const SYSTEM_PROMPT = `You are an expert on the Google People API for contacts. When given an API error and the JSON payload that caused it, provide:
44
+
45
+ 1. A brief explanation of what went wrong
46
+ 2. The specific fix needed
47
+ 3. If possible, the corrected JSON snippet
48
+
49
+ Be extremely concise. No preamble. Format:
50
+ PROBLEM: <one line>
51
+ FIX: <one line>
52
+ CORRECTED: <json if applicable, or "N/A">
53
+
54
+ Common issues:
55
+ - nicknames is a top-level field, NOT nested in names[]
56
+ - phoneNumbers[].type values: "mobile", "home", "work", "homeFax", "workFax", "otherFax", "pager", "workMobile", "workPager", "main", "googleVoice", "other"
57
+ - emailAddresses[].type values: "home", "work", "other"
58
+ - addresses[].type values: "home", "work", "other"
59
+ - birthdays[].date needs {year?, month, day} not a string
60
+ - organizations[].type values: "work", "school"
61
+ - Field names are camelCase not snake_case`;
62
+
63
+ async function callAnthropic(errorMsg: string, payload: string): Promise<string | null> {
64
+ const apiKey = process.env.ANTHROPIC_API_KEY;
65
+ if (!apiKey) return null;
66
+
67
+ try {
68
+ const response = await fetch('https://api.anthropic.com/v1/messages', {
69
+ method: 'POST',
70
+ headers: {
71
+ 'Content-Type': 'application/json',
72
+ 'x-api-key': apiKey,
73
+ 'anthropic-version': '2023-06-01'
74
+ },
75
+ body: JSON.stringify({
76
+ model: 'claude-3-haiku-20240307',
77
+ max_tokens: 500,
78
+ system: SYSTEM_PROMPT,
79
+ messages: [{
80
+ role: 'user',
81
+ content: `Error: ${errorMsg}\n\nPayload:\n${payload}`
82
+ }]
83
+ })
84
+ });
85
+
86
+ if (!response.ok) return null;
87
+
88
+ const data = await response.json() as any;
89
+ return data.content?.[0]?.text || null;
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
94
+
95
+ async function callOpenAI(errorMsg: string, payload: string): Promise<string | null> {
96
+ const apiKey = process.env.OPENAI_API_KEY;
97
+ if (!apiKey) return null;
98
+
99
+ try {
100
+ const response = await fetch('https://api.openai.com/v1/chat/completions', {
101
+ method: 'POST',
102
+ headers: {
103
+ 'Content-Type': 'application/json',
104
+ 'Authorization': `Bearer ${apiKey}`
105
+ },
106
+ body: JSON.stringify({
107
+ model: 'gpt-4o-mini',
108
+ max_tokens: 500,
109
+ messages: [
110
+ { role: 'system', content: SYSTEM_PROMPT },
111
+ { role: 'user', content: `Error: ${errorMsg}\n\nPayload:\n${payload}` }
112
+ ]
113
+ })
114
+ });
115
+
116
+ if (!response.ok) return null;
117
+
118
+ const data = await response.json() as any;
119
+ return data.choices?.[0]?.message?.content || null;
120
+ } catch {
121
+ return null;
122
+ }
123
+ }
124
+
125
+ function parseAIResponse(response: string): AIErrorExplanation {
126
+ const lines = response.split('\n');
127
+ let explanation = '';
128
+ let suggestedFix = '';
129
+ let correctedJson: string | undefined;
130
+ let inCorrected = false;
131
+ const correctedLines: string[] = [];
132
+
133
+ for (const line of lines) {
134
+ if (line.startsWith('PROBLEM:')) {
135
+ explanation = line.substring(8).trim();
136
+ } else if (line.startsWith('FIX:')) {
137
+ suggestedFix = line.substring(4).trim();
138
+ } else if (line.startsWith('CORRECTED:')) {
139
+ const rest = line.substring(10).trim();
140
+ if (rest && rest !== 'N/A') {
141
+ inCorrected = true;
142
+ if (rest !== '{' && rest !== '[') {
143
+ correctedLines.push(rest);
144
+ } else {
145
+ correctedLines.push(rest);
146
+ }
147
+ }
148
+ } else if (inCorrected) {
149
+ correctedLines.push(line);
150
+ }
151
+ }
152
+
153
+ if (correctedLines.length > 0) {
154
+ const joined = correctedLines.join('\n').trim();
155
+ // Try to extract just the JSON part
156
+ const jsonMatch = joined.match(/(\{[\s\S]*\}|\[[\s\S]*\])/);
157
+ if (jsonMatch) {
158
+ correctedJson = jsonMatch[1];
159
+ } else if (joined !== 'N/A') {
160
+ correctedJson = joined;
161
+ }
162
+ }
163
+
164
+ return { explanation, suggestedFix, correctedJson };
165
+ }
166
+
167
+ /**
168
+ * Get AI-powered explanation for a Google People API error
169
+ * @param errorMessage The error message from the API
170
+ * @param payload The JSON payload that caused the error (will be truncated if too long)
171
+ * @returns Explanation and fix suggestion, or null if AI unavailable
172
+ */
173
+ export async function explainApiError(errorMessage: string, payload: unknown): Promise<AIErrorExplanation | null> {
174
+ // Truncate payload to avoid huge API calls
175
+ let payloadStr = typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2);
176
+ if (payloadStr.length > 2000) {
177
+ payloadStr = payloadStr.substring(0, 2000) + '\n... (truncated)';
178
+ }
179
+
180
+ // Try Anthropic first, then OpenAI
181
+ let response = await callAnthropic(errorMessage, payloadStr);
182
+ if (!response) {
183
+ response = await callOpenAI(errorMessage, payloadStr);
184
+ }
185
+
186
+ if (!response) return null;
187
+
188
+ return parseAIResponse(response);
189
+ }
190
+
191
+ /**
192
+ * Check if AI error explanation is available (any API key configured)
193
+ */
194
+ export function isAIAvailable(): boolean {
195
+ return !!(process.env.ANTHROPIC_API_KEY || process.env.OPENAI_API_KEY);
196
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/gcards",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "description": "Google Contacts cleanup and management tool",
5
5
  "type": "module",
6
6
  "main": "gcards.ts",