gencode-ai 0.3.0 → 0.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.
Files changed (116) hide show
  1. package/RELEASE_NOTES_v0.4.0.md +140 -0
  2. package/dist/agent/agent.d.ts +17 -2
  3. package/dist/agent/agent.d.ts.map +1 -1
  4. package/dist/agent/agent.js +279 -49
  5. package/dist/agent/agent.js.map +1 -1
  6. package/dist/agent/types.d.ts +15 -1
  7. package/dist/agent/types.d.ts.map +1 -1
  8. package/dist/checkpointing/checkpoint-manager.d.ts +24 -0
  9. package/dist/checkpointing/checkpoint-manager.d.ts.map +1 -1
  10. package/dist/checkpointing/checkpoint-manager.js +28 -0
  11. package/dist/checkpointing/checkpoint-manager.js.map +1 -1
  12. package/dist/cli/components/App.d.ts +8 -0
  13. package/dist/cli/components/App.d.ts.map +1 -1
  14. package/dist/cli/components/App.js +478 -36
  15. package/dist/cli/components/App.js.map +1 -1
  16. package/dist/cli/components/CommandSuggestions.d.ts.map +1 -1
  17. package/dist/cli/components/CommandSuggestions.js +2 -0
  18. package/dist/cli/components/CommandSuggestions.js.map +1 -1
  19. package/dist/cli/components/Header.d.ts +6 -1
  20. package/dist/cli/components/Header.d.ts.map +1 -1
  21. package/dist/cli/components/Header.js +3 -3
  22. package/dist/cli/components/Header.js.map +1 -1
  23. package/dist/cli/components/Messages.d.ts.map +1 -1
  24. package/dist/cli/components/Messages.js +7 -9
  25. package/dist/cli/components/Messages.js.map +1 -1
  26. package/dist/cli/index.js +3 -2
  27. package/dist/cli/index.js.map +1 -1
  28. package/dist/config/types.d.ts +20 -1
  29. package/dist/config/types.d.ts.map +1 -1
  30. package/dist/config/types.js.map +1 -1
  31. package/dist/index.d.ts +2 -2
  32. package/dist/index.js +2 -2
  33. package/dist/input/history-manager.d.ts +78 -0
  34. package/dist/input/history-manager.d.ts.map +1 -0
  35. package/dist/input/history-manager.js +224 -0
  36. package/dist/input/history-manager.js.map +1 -0
  37. package/dist/input/index.d.ts +6 -0
  38. package/dist/input/index.d.ts.map +1 -0
  39. package/dist/input/index.js +5 -0
  40. package/dist/input/index.js.map +1 -0
  41. package/dist/prompts/index.js +3 -3
  42. package/dist/prompts/index.js.map +1 -1
  43. package/dist/providers/gemini.d.ts.map +1 -1
  44. package/dist/providers/gemini.js +33 -2
  45. package/dist/providers/gemini.js.map +1 -1
  46. package/dist/providers/google.d.ts +22 -0
  47. package/dist/providers/google.d.ts.map +1 -0
  48. package/dist/providers/google.js +297 -0
  49. package/dist/providers/google.js.map +1 -0
  50. package/dist/providers/index.d.ts +4 -4
  51. package/dist/providers/index.js +11 -11
  52. package/dist/providers/index.js.map +1 -1
  53. package/dist/providers/openai.d.ts.map +1 -1
  54. package/dist/providers/openai.js +6 -0
  55. package/dist/providers/openai.js.map +1 -1
  56. package/dist/providers/registry.js +3 -3
  57. package/dist/providers/registry.js.map +1 -1
  58. package/dist/providers/types.d.ts +30 -4
  59. package/dist/providers/types.d.ts.map +1 -1
  60. package/dist/session/compression/engine.d.ts +109 -0
  61. package/dist/session/compression/engine.d.ts.map +1 -0
  62. package/dist/session/compression/engine.js +311 -0
  63. package/dist/session/compression/engine.js.map +1 -0
  64. package/dist/session/compression/index.d.ts +12 -0
  65. package/dist/session/compression/index.d.ts.map +1 -0
  66. package/dist/session/compression/index.js +11 -0
  67. package/dist/session/compression/index.js.map +1 -0
  68. package/dist/session/compression/types.d.ts +90 -0
  69. package/dist/session/compression/types.d.ts.map +1 -0
  70. package/dist/session/compression/types.js +17 -0
  71. package/dist/session/compression/types.js.map +1 -0
  72. package/dist/session/manager.d.ts +64 -3
  73. package/dist/session/manager.d.ts.map +1 -1
  74. package/dist/session/manager.js +254 -2
  75. package/dist/session/manager.js.map +1 -1
  76. package/dist/session/types.d.ts +16 -0
  77. package/dist/session/types.d.ts.map +1 -1
  78. package/dist/session/types.js.map +1 -1
  79. package/docs/README.md +1 -0
  80. package/docs/diagrams/compression-decision.mmd +30 -0
  81. package/docs/diagrams/compression-workflow.mmd +54 -0
  82. package/docs/diagrams/layer1-pruning.mmd +45 -0
  83. package/docs/diagrams/layer2-compaction.mmd +42 -0
  84. package/docs/proposals/0007-context-management.md +252 -2
  85. package/docs/proposals/README.md +4 -3
  86. package/docs/providers.md +3 -3
  87. package/docs/session-compression.md +695 -0
  88. package/examples/agent-demo.ts +23 -1
  89. package/examples/basic.ts +3 -3
  90. package/package.json +3 -4
  91. package/src/agent/agent.ts +314 -52
  92. package/src/agent/types.ts +19 -1
  93. package/src/checkpointing/checkpoint-manager.ts +48 -0
  94. package/src/cli/components/App.tsx +553 -34
  95. package/src/cli/components/CommandSuggestions.tsx +2 -0
  96. package/src/cli/components/Header.tsx +16 -1
  97. package/src/cli/components/Messages.tsx +20 -14
  98. package/src/cli/index.tsx +3 -2
  99. package/src/config/types.ts +26 -1
  100. package/src/index.ts +3 -3
  101. package/src/input/history-manager.ts +289 -0
  102. package/src/input/index.ts +6 -0
  103. package/src/prompts/index.test.ts +2 -1
  104. package/src/prompts/index.ts +3 -3
  105. package/src/providers/{gemini.ts → google.ts} +69 -18
  106. package/src/providers/index.ts +14 -14
  107. package/src/providers/openai.ts +7 -0
  108. package/src/providers/registry.ts +3 -3
  109. package/src/providers/types.ts +33 -3
  110. package/src/session/compression/engine.ts +406 -0
  111. package/src/session/compression/index.ts +18 -0
  112. package/src/session/compression/types.ts +102 -0
  113. package/src/session/manager.ts +326 -3
  114. package/src/session/types.ts +21 -0
  115. package/tests/input-history-manager.test.ts +335 -0
  116. package/tests/session-checkpoint-persistence.test.ts +198 -0
@@ -25,6 +25,8 @@ export const COMMANDS: Command[] = [
25
25
  { name: '/memory', description: 'Show memory files' },
26
26
  { name: '/changes', description: 'List file changes' },
27
27
  { name: '/rewind', description: 'Undo file changes' },
28
+ { name: '/context', description: 'Show context usage stats' },
29
+ { name: '/compact', description: 'Manually compact conversation' },
28
30
  ];
29
31
 
30
32
  interface CommandSuggestionsProps {
@@ -6,9 +6,14 @@ interface HeaderProps {
6
6
  provider: string;
7
7
  model: string;
8
8
  cwd: string;
9
+ contextStats?: {
10
+ activeMessages: number;
11
+ totalMessages: number;
12
+ usagePercent: number;
13
+ };
9
14
  }
10
15
 
11
- export function Header({ model, cwd }: HeaderProps) {
16
+ export function Header({ model, cwd, contextStats }: HeaderProps) {
12
17
  return (
13
18
  <Box flexDirection="column" marginTop={1}>
14
19
  <BigLogo />
@@ -16,6 +21,16 @@ export function Header({ model, cwd }: HeaderProps) {
16
21
  <Text color={colors.textSecondary}>{model}</Text>
17
22
  <Text color={colors.textMuted}> · </Text>
18
23
  <Text color={colors.textMuted}>{cwd}</Text>
24
+
25
+ {contextStats && contextStats.activeMessages > 0 && (
26
+ <>
27
+ <Text color={colors.textMuted}> · </Text>
28
+ <Text color={colors.textSecondary}>
29
+ Context: {contextStats.activeMessages}/{contextStats.totalMessages} msgs
30
+ </Text>
31
+ <Text color={colors.textMuted}> ({Math.round(contextStats.usagePercent)}%)</Text>
32
+ </>
33
+ )}
19
34
  </Box>
20
35
  </Box>
21
36
  );
@@ -110,29 +110,35 @@ interface ToolCallProps {
110
110
  function formatToolInput(name: string, input: Record<string, unknown>): string {
111
111
  switch (name) {
112
112
  case 'Read':
113
- return input.file_path as string || '';
114
113
  case 'Write':
115
114
  case 'Edit':
116
- return input.file_path as string || '';
115
+ return (input.file_path as string) || '';
116
+
117
117
  case 'Glob':
118
- return input.pattern as string || '';
119
- case 'Grep':
120
- return `"${input.pattern}"` + (input.path ? ` in ${input.path}` : '');
118
+ return (input.pattern as string) || '';
119
+
120
+ case 'Grep': {
121
+ const pattern = `"${input.pattern}"`;
122
+ return input.path ? `${pattern} in ${input.path}` : pattern;
123
+ }
124
+
121
125
  case 'Bash':
122
- return truncate(input.command as string || '', 50);
126
+ return truncate((input.command as string) || '', 50);
127
+
123
128
  case 'WebFetch':
124
- return input.url as string || '';
129
+ return (input.url as string) || '';
130
+
125
131
  case 'WebSearch':
126
132
  return `"${input.query}"` || '';
133
+
127
134
  case 'TodoWrite': {
128
- const todos = input.todos as Array<{ content: string; status: string }> || [];
135
+ const todos = (input.todos as Array<{ content: string; status: string }>) || [];
129
136
  return `${todos.length} task${todos.length !== 1 ? 's' : ''}`;
130
137
  }
131
- case 'AskUserQuestion': {
132
- // Show collapsed JSON preview
133
- const json = JSON.stringify(input);
134
- return truncate(json, 60);
135
- }
138
+
139
+ case 'AskUserQuestion':
140
+ return truncate(JSON.stringify(input), 60);
141
+
136
142
  default:
137
143
  return truncate(JSON.stringify(input), 40);
138
144
  }
@@ -368,7 +374,7 @@ export function CompletionMessage({ durationMs, usage, cost }: CompletionMessage
368
374
  );
369
375
  }
370
376
 
371
- if (cost) {
377
+ if (cost && cost.totalCost > 0) {
372
378
  parts.push(`(~${formatCost(cost.totalCost)})`);
373
379
  }
374
380
 
package/src/cli/index.tsx CHANGED
@@ -34,7 +34,7 @@ async function setupProxy(): Promise<void> {
34
34
  // Configuration
35
35
  // ============================================================================
36
36
  function detectConfig(settings: Settings, providersConfig: ProvidersConfigManager): AgentConfig {
37
- let provider: Provider = 'gemini';
37
+ let provider: Provider = 'google';
38
38
  let authMethod: AuthMethod | undefined;
39
39
  let model = 'gemini-2.0-flash';
40
40
 
@@ -60,7 +60,7 @@ function detectConfig(settings: Settings, providersConfig: ProvidersConfigManage
60
60
  authMethod = 'api_key';
61
61
  model = 'gpt-4o';
62
62
  } else if (process.env.GOOGLE_API_KEY) {
63
- provider = 'gemini';
63
+ provider = 'google';
64
64
  authMethod = 'api_key';
65
65
  model = 'gemini-2.0-flash';
66
66
  }
@@ -99,6 +99,7 @@ function detectConfig(settings: Settings, providersConfig: ProvidersConfigManage
99
99
  model,
100
100
  cwd: process.cwd(),
101
101
  maxTurns: 20,
102
+ compression: settings.compression,
102
103
  };
103
104
  }
104
105
 
@@ -17,7 +17,7 @@ import * as path from 'path';
17
17
  // Provider Types
18
18
  // =============================================================================
19
19
 
20
- export type Provider = 'openai' | 'anthropic' | 'gemini';
20
+ export type Provider = 'openai' | 'anthropic' | 'google';
21
21
  export type AuthMethod = 'api_key' | 'vertex' | 'bedrock' | 'azure' | 'oauth';
22
22
 
23
23
  // Legacy type alias for backward compatibility
@@ -70,6 +70,31 @@ export interface Settings {
70
70
  // Memory configuration
71
71
  memoryMergeStrategy?: 'fallback' | 'both' | 'gen-only' | 'claude-only';
72
72
 
73
+ // Compression configuration
74
+ compression?: {
75
+ enabled?: boolean;
76
+ enablePruning?: boolean;
77
+ enableCompaction?: boolean;
78
+ pruneMinimum?: number;
79
+ pruneProtect?: number;
80
+ reservedOutputTokens?: number;
81
+ model?: string;
82
+ };
83
+
84
+ // Input history configuration
85
+ inputHistory?: {
86
+ enabled?: boolean;
87
+ maxSize?: number;
88
+ savePath?: string;
89
+ deduplicateConsecutive?: boolean;
90
+ };
91
+
92
+ // Input behavior configuration
93
+ input?: {
94
+ multilineEnabled?: boolean; // Default: true - Enable Shift+Enter for multi-line input
95
+ ctrlCClear?: boolean; // Default: true - Clear input on Ctrl+C if text present
96
+ };
97
+
73
98
  // Managed-only fields (cannot be overridden by lower levels)
74
99
  strictKnownMarketplaces?: unknown[];
75
100
 
package/src/index.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Recode - Multi-LLM Agent SDK
3
3
  *
4
4
  * A unified SDK for building AI agents with support for
5
- * OpenAI, Anthropic, and Google Gemini models.
5
+ * OpenAI, Anthropic, and Google models.
6
6
  */
7
7
 
8
8
  // Providers
@@ -24,13 +24,13 @@ export {
24
24
  type StopReason,
25
25
  type OpenAIConfig,
26
26
  type AnthropicConfig,
27
- type GeminiConfig,
27
+ type GoogleConfig,
28
28
  type ProviderConfig,
29
29
  type ProviderName,
30
30
  // Providers
31
31
  OpenAIProvider,
32
32
  AnthropicProvider,
33
- GeminiProvider,
33
+ GoogleProvider,
34
34
  // Factory
35
35
  createProvider,
36
36
  inferProvider,
@@ -0,0 +1,289 @@
1
+ /**
2
+ * Input History Manager
3
+ * Manages command history with persistence, navigation, and deduplication
4
+ */
5
+
6
+ import { promises as fs } from 'fs';
7
+ import { homedir } from 'os';
8
+ import { join, dirname } from 'path';
9
+
10
+ export interface HistoryEntry {
11
+ text: string;
12
+ timestamp: string;
13
+ }
14
+
15
+ export interface HistoryConfig {
16
+ enabled?: boolean;
17
+ maxSize?: number;
18
+ savePath?: string;
19
+ deduplicateConsecutive?: boolean;
20
+ }
21
+
22
+ interface HistoryFile {
23
+ entries: HistoryEntry[];
24
+ maxSize: number;
25
+ }
26
+
27
+ const DEFAULT_CONFIG: Required<HistoryConfig> = {
28
+ enabled: true,
29
+ maxSize: 1000,
30
+ savePath: '~/.gen/input-history.json',
31
+ deduplicateConsecutive: true,
32
+ };
33
+
34
+ export class InputHistoryManager {
35
+ private entries: HistoryEntry[] = [];
36
+ private currentPosition = -1; // -1 means not navigating
37
+ private config: Required<HistoryConfig>;
38
+ private savePath: string;
39
+ private saveTimeout: NodeJS.Timeout | null = null;
40
+ private isLoaded = false;
41
+
42
+ constructor(config: HistoryConfig = {}) {
43
+ this.config = { ...DEFAULT_CONFIG, ...config };
44
+ this.savePath = this.resolveTildePath(this.config.savePath);
45
+ }
46
+
47
+ /**
48
+ * Resolve ~ to home directory
49
+ */
50
+ private resolveTildePath(path: string): string {
51
+ return path.startsWith('~/') ? join(homedir(), path.slice(2)) : path;
52
+ }
53
+
54
+ /**
55
+ * Load history from disk
56
+ */
57
+ async load(): Promise<void> {
58
+ if (!this.config.enabled) {
59
+ return;
60
+ }
61
+
62
+ try {
63
+ const data = await fs.readFile(this.savePath, 'utf-8');
64
+ const historyFile: HistoryFile = JSON.parse(data);
65
+
66
+ // Validate and load entries
67
+ if (Array.isArray(historyFile.entries)) {
68
+ this.entries = historyFile.entries.filter(
69
+ (entry) => entry && typeof entry.text === 'string'
70
+ );
71
+
72
+ // Prune if maxSize changed
73
+ if (this.entries.length > this.config.maxSize) {
74
+ this.entries = this.entries.slice(-this.config.maxSize);
75
+ }
76
+ }
77
+
78
+ this.isLoaded = true;
79
+ } catch (error: unknown) {
80
+ // File doesn't exist or is corrupt - start with empty history
81
+ if (error instanceof Error && 'code' in error && error.code !== 'ENOENT') {
82
+ console.error('Failed to load input history:', error.message);
83
+ }
84
+ this.entries = [];
85
+ this.isLoaded = true;
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Save history to disk (async, debounced)
91
+ */
92
+ async save(): Promise<void> {
93
+ if (!this.config.enabled || !this.isLoaded) {
94
+ return;
95
+ }
96
+
97
+ // Debounce saves to avoid excessive writes
98
+ if (this.saveTimeout) {
99
+ clearTimeout(this.saveTimeout);
100
+ }
101
+
102
+ this.saveTimeout = setTimeout(async () => {
103
+ try {
104
+ // Ensure directory exists
105
+ const dir = dirname(this.savePath);
106
+ await fs.mkdir(dir, { recursive: true });
107
+
108
+ const historyFile: HistoryFile = {
109
+ entries: this.entries,
110
+ maxSize: this.config.maxSize,
111
+ };
112
+
113
+ await fs.writeFile(
114
+ this.savePath,
115
+ JSON.stringify(historyFile, null, 2),
116
+ 'utf-8'
117
+ );
118
+ } catch (error: unknown) {
119
+ // Log but don't throw - saving history should not break the app
120
+ const message = error instanceof Error ? error.message : String(error);
121
+ console.error('Failed to save input history:', message);
122
+ }
123
+ }, 100); // 100ms debounce
124
+ }
125
+
126
+ /**
127
+ * Flush pending saves immediately (for app exit)
128
+ */
129
+ async flush(): Promise<void> {
130
+ if (this.saveTimeout) {
131
+ clearTimeout(this.saveTimeout);
132
+ this.saveTimeout = null;
133
+ }
134
+
135
+ if (!this.config.enabled || !this.isLoaded) {
136
+ return;
137
+ }
138
+
139
+ try {
140
+ const dir = dirname(this.savePath);
141
+ await fs.mkdir(dir, { recursive: true });
142
+
143
+ const historyFile: HistoryFile = {
144
+ entries: this.entries,
145
+ maxSize: this.config.maxSize,
146
+ };
147
+
148
+ await fs.writeFile(
149
+ this.savePath,
150
+ JSON.stringify(historyFile, null, 2),
151
+ 'utf-8'
152
+ );
153
+ } catch (error: unknown) {
154
+ const message = error instanceof Error ? error.message : String(error);
155
+ console.error('Failed to flush input history:', message);
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Add a new entry to history
161
+ */
162
+ add(text: string): void {
163
+ if (!this.config.enabled || !text.trim()) {
164
+ return;
165
+ }
166
+
167
+ const trimmedText = text.trim();
168
+
169
+ // Deduplicate consecutive entries
170
+ if (this.config.deduplicateConsecutive && this.entries.length > 0) {
171
+ const lastEntry = this.entries[this.entries.length - 1];
172
+ if (lastEntry.text === trimmedText) {
173
+ return; // Skip duplicate
174
+ }
175
+ }
176
+
177
+ // Add new entry
178
+ this.entries.push({
179
+ text: trimmedText,
180
+ timestamp: new Date().toISOString(),
181
+ });
182
+
183
+ // Prune old entries if exceeding maxSize
184
+ if (this.entries.length > this.config.maxSize) {
185
+ this.entries = this.entries.slice(-this.config.maxSize);
186
+ }
187
+
188
+ // Reset navigation position
189
+ this.currentPosition = -1;
190
+
191
+ // Save asynchronously
192
+ void this.save();
193
+ }
194
+
195
+ /**
196
+ * Navigate to previous entry (older)
197
+ * Returns the entry text or null if at beginning
198
+ */
199
+ previous(): string | null {
200
+ if (!this.config.enabled || this.entries.length === 0) {
201
+ return null;
202
+ }
203
+
204
+ // First time navigating - start from end
205
+ if (this.currentPosition === -1) {
206
+ this.currentPosition = this.entries.length - 1;
207
+ return this.entries[this.currentPosition].text;
208
+ }
209
+
210
+ // Already at beginning
211
+ if (this.currentPosition === 0) {
212
+ return this.entries[0].text;
213
+ }
214
+
215
+ // Move to previous entry
216
+ this.currentPosition--;
217
+ return this.entries[this.currentPosition].text;
218
+ }
219
+
220
+ /**
221
+ * Navigate to next entry (newer)
222
+ * Returns the entry text or null if at end (original input)
223
+ */
224
+ next(): string | null {
225
+ if (!this.config.enabled || this.entries.length === 0) {
226
+ return null;
227
+ }
228
+
229
+ // Not navigating or at end
230
+ if (this.currentPosition === -1) {
231
+ return null;
232
+ }
233
+
234
+ // Move to next entry
235
+ this.currentPosition++;
236
+
237
+ // Reached end - return to original input
238
+ if (this.currentPosition >= this.entries.length) {
239
+ this.currentPosition = -1;
240
+ return null; // Signal to restore original input
241
+ }
242
+
243
+ return this.entries[this.currentPosition].text;
244
+ }
245
+
246
+ /**
247
+ * Reset navigation state (cancel history navigation)
248
+ */
249
+ reset(): void {
250
+ this.currentPosition = -1;
251
+ }
252
+
253
+ /**
254
+ * Check if currently navigating history
255
+ */
256
+ isNavigating(): boolean {
257
+ return this.currentPosition !== -1;
258
+ }
259
+
260
+ /**
261
+ * Get current position in history (-1 if not navigating)
262
+ */
263
+ getPosition(): number {
264
+ return this.currentPosition;
265
+ }
266
+
267
+ /**
268
+ * Get total number of entries
269
+ */
270
+ size(): number {
271
+ return this.entries.length;
272
+ }
273
+
274
+ /**
275
+ * Get all entries (for debugging/testing)
276
+ */
277
+ getEntries(): readonly HistoryEntry[] {
278
+ return this.entries;
279
+ }
280
+
281
+ /**
282
+ * Clear all history
283
+ */
284
+ clear(): void {
285
+ this.entries = [];
286
+ this.currentPosition = -1;
287
+ void this.save();
288
+ }
289
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Input module - history management and enhanced input components
3
+ */
4
+
5
+ export { InputHistoryManager } from './history-manager.js';
6
+ export type { HistoryEntry, HistoryConfig } from './history-manager.js';
@@ -104,7 +104,8 @@ describe('Prompt Loader', () => {
104
104
  it('should map known providers correctly', () => {
105
105
  expect(mapProviderToPromptType('anthropic')).toBe('anthropic');
106
106
  expect(mapProviderToPromptType('openai')).toBe('openai');
107
- expect(mapProviderToPromptType('gemini')).toBe('gemini');
107
+ // Google provider uses Gemini models, so it maps to gemini prompt
108
+ expect(mapProviderToPromptType('google')).toBe('gemini');
108
109
  });
109
110
 
110
111
  it('should return generic for unknown providers', () => {
@@ -83,7 +83,7 @@ export function getProviderForModel(model: string): string | null {
83
83
  * Handles both "provider" and "provider:authMethod" formats
84
84
  */
85
85
  export function mapProviderToPromptType(provider: string): ProviderType {
86
- // Extract provider prefix (e.g., "gemini:api_key" → "gemini")
86
+ // Extract provider prefix (e.g., "google:api_key" → "google")
87
87
  const providerPrefix = provider.split(':')[0];
88
88
 
89
89
  switch (providerPrefix) {
@@ -91,8 +91,8 @@ export function mapProviderToPromptType(provider: string): ProviderType {
91
91
  return 'anthropic';
92
92
  case 'openai':
93
93
  return 'openai';
94
- case 'gemini':
95
- return 'gemini';
94
+ case 'google':
95
+ return 'gemini'; // Google provider uses Gemini models, so use gemini prompt
96
96
  default:
97
97
  return 'generic';
98
98
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Google Gemini Provider Implementation
3
- * Supports Gemini 1.5 Pro, Gemini 1.5 Flash, Gemini 2.0, etc.
2
+ * Google Provider Implementation
3
+ * Supports Gemini models: Gemini 1.5 Pro, Gemini 1.5 Flash, Gemini 2.0, etc.
4
4
  */
5
5
 
6
6
  import { GoogleGenerativeAI, SchemaType } from '@google/generative-ai';
@@ -16,28 +16,28 @@ import type {
16
16
  MessageContent,
17
17
  ToolDefinition,
18
18
  StopReason,
19
- GeminiConfig,
19
+ GoogleConfig,
20
20
  JSONSchema,
21
21
  ModelInfo,
22
22
  } from './types.js';
23
23
 
24
- export class GeminiProvider implements LLMProvider {
24
+ export class GoogleProvider implements LLMProvider {
25
25
  static readonly meta: ProviderClassMeta = {
26
- provider: 'gemini',
26
+ provider: 'google',
27
27
  authMethod: 'api_key',
28
28
  envVars: ['GOOGLE_API_KEY', 'GEMINI_API_KEY'],
29
- displayName: 'Direct API',
30
- description: 'Direct API access',
29
+ displayName: 'Google',
30
+ description: 'Google Generative AI (Gemini models)',
31
31
  };
32
32
 
33
- readonly name = 'gemini';
33
+ readonly name = 'google';
34
34
  private client: GoogleGenerativeAI;
35
35
  private apiKey: string;
36
36
 
37
- constructor(config: GeminiConfig = {}) {
37
+ constructor(config: GoogleConfig = {}) {
38
38
  const apiKey = config.apiKey ?? process.env.GOOGLE_API_KEY ?? process.env.GEMINI_API_KEY;
39
39
  if (!apiKey) {
40
- throw new Error('Gemini API key is required. Set GOOGLE_API_KEY or GEMINI_API_KEY.');
40
+ throw new Error('Google API key is required. Set GOOGLE_API_KEY or GEMINI_API_KEY.');
41
41
  }
42
42
  this.apiKey = apiKey;
43
43
  this.client = new GoogleGenerativeAI(apiKey);
@@ -95,6 +95,12 @@ export class GeminiProvider implements LLMProvider {
95
95
  const id = `call_${callIndex++}`;
96
96
  // Capture thoughtSignature for Gemini 3+ models
97
97
  const partAny = part as { thoughtSignature?: string };
98
+
99
+ // Emit reasoning content if available (Gemini 3+ thinking)
100
+ if (partAny.thoughtSignature) {
101
+ yield { type: 'reasoning', text: partAny.thoughtSignature };
102
+ }
103
+
98
104
  functionCalls.push({
99
105
  id,
100
106
  name: fc.name,
@@ -125,13 +131,31 @@ export class GeminiProvider implements LLMProvider {
125
131
  const finalResponse = await result.response;
126
132
  const stopReason = this.getStopReason(finalResponse, functionCalls.length > 0);
127
133
 
134
+ // Debug: Log usage metadata
135
+ if (process.env.DEBUG_TOKENS) {
136
+ console.error('[Google] usageMetadata:', JSON.stringify(finalResponse.usageMetadata, null, 2));
137
+ }
138
+
128
139
  const usage = finalResponse.usageMetadata
129
140
  ? {
130
141
  inputTokens: finalResponse.usageMetadata.promptTokenCount ?? 0,
131
- outputTokens: finalResponse.usageMetadata.candidatesTokenCount ?? 0,
142
+ // Fix: candidatesTokenCount is unreliable, calculate from total - prompt
143
+ // Ensure outputTokens is never negative
144
+ outputTokens: Math.max(
145
+ 0,
146
+ (finalResponse.usageMetadata.totalTokenCount ?? 0) - (finalResponse.usageMetadata.promptTokenCount ?? 0)
147
+ ),
132
148
  }
133
149
  : undefined;
134
150
 
151
+ // Warn if suspicious token count
152
+ if (usage && usage.outputTokens === 0 && content.length > 0) {
153
+ console.warn(
154
+ '[Google] Warning: usageMetadata shows 0 output tokens but content was returned. ' +
155
+ 'This may indicate a Gemini API issue.'
156
+ );
157
+ }
158
+
135
159
  const cost = usage ? calculateCost(this.name, options.model, usage) : undefined;
136
160
 
137
161
  yield {
@@ -276,13 +300,31 @@ export class GeminiProvider implements LLMProvider {
276
300
 
277
301
  const hasFunctionCalls = parts.some((p) => 'functionCall' in p);
278
302
 
303
+ // Debug: Log usage metadata
304
+ if (process.env.DEBUG_TOKENS) {
305
+ console.error('[Google complete] usageMetadata:', JSON.stringify(response.usageMetadata, null, 2));
306
+ }
307
+
279
308
  const usage = response.usageMetadata
280
309
  ? {
281
310
  inputTokens: response.usageMetadata.promptTokenCount ?? 0,
282
- outputTokens: response.usageMetadata.candidatesTokenCount ?? 0,
311
+ // Fix: candidatesTokenCount is unreliable, calculate from total - prompt
312
+ // Ensure outputTokens is never negative
313
+ outputTokens: Math.max(
314
+ 0,
315
+ (response.usageMetadata.totalTokenCount ?? 0) - (response.usageMetadata.promptTokenCount ?? 0)
316
+ ),
283
317
  }
284
318
  : undefined;
285
319
 
320
+ // Warn if suspicious token count
321
+ if (usage && usage.outputTokens === 0 && content.length > 0) {
322
+ console.warn(
323
+ '[Google] Warning: usageMetadata shows 0 output tokens but content was returned. ' +
324
+ 'This may indicate a Gemini API issue.'
325
+ );
326
+ }
327
+
286
328
  const cost = usage ? calculateCost(this.name, model, usage) : undefined;
287
329
 
288
330
  return {
@@ -299,13 +341,22 @@ export class GeminiProvider implements LLMProvider {
299
341
  }
300
342
 
301
343
  const finishReason = response.candidates?.[0]?.finishReason;
302
- switch (finishReason) {
303
- case 'MAX_TOKENS':
304
- return 'max_tokens';
305
- case 'STOP':
306
- default:
307
- return 'end_turn';
344
+
345
+ if (finishReason === 'MAX_TOKENS') {
346
+ return 'max_tokens';
347
+ }
348
+
349
+ if (finishReason === 'STOP') {
350
+ return 'end_turn';
351
+ }
352
+
353
+ if (finishReason === 'SAFETY' || finishReason === 'RECITATION') {
354
+ console.warn(`[Google] Content blocked by ${finishReason} filter`);
355
+ return 'end_turn';
308
356
  }
357
+
358
+ console.warn(`[Google] Unknown finishReason: ${finishReason}`);
359
+ return 'end_turn';
309
360
  }
310
361
 
311
362
  async listModels(): Promise<ModelInfo[]> {