pikiloop 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 (154) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +353 -0
  3. package/README.v2.md +287 -0
  4. package/README.zh-CN.md +352 -0
  5. package/dashboard/dist/assets/AgentTab-UZPIhlkr.js +1 -0
  6. package/dashboard/dist/assets/DirBrowser-Ckcmi-Pi.js +1 -0
  7. package/dashboard/dist/assets/ExtensionsTab-KZhEDrdu.js +1 -0
  8. package/dashboard/dist/assets/IMAccessTab-Bd_IY1GQ.js +1 -0
  9. package/dashboard/dist/assets/Modal-CTeL0y7P.js +1 -0
  10. package/dashboard/dist/assets/Modals-axftHasy.js +1 -0
  11. package/dashboard/dist/assets/Select-C8tOdPhe.js +1 -0
  12. package/dashboard/dist/assets/SessionPanel-C1geSRxw.js +1 -0
  13. package/dashboard/dist/assets/SystemTab-DBDkaPiO.js +1 -0
  14. package/dashboard/dist/assets/anthropic-BAdojD7P.ico +0 -0
  15. package/dashboard/dist/assets/codex-DYadqqp0.png +0 -0
  16. package/dashboard/dist/assets/deepseek-BeYNZEk0.ico +0 -0
  17. package/dashboard/dist/assets/doubao-DloFDuFR.png +0 -0
  18. package/dashboard/dist/assets/feishu-C4OMrjCW.ico +0 -0
  19. package/dashboard/dist/assets/gemini-BYkEpiWr.svg +1 -0
  20. package/dashboard/dist/assets/hermes-BAarh-tH.png +0 -0
  21. package/dashboard/dist/assets/index-CpM4CqZJ.js +23 -0
  22. package/dashboard/dist/assets/index-DXSohzrE.js +3 -0
  23. package/dashboard/dist/assets/index-reSbuley.css +1 -0
  24. package/dashboard/dist/assets/markdown-DxQYQFeH.js +29 -0
  25. package/dashboard/dist/assets/minimax-PuEGTfrF.ico +0 -0
  26. package/dashboard/dist/assets/mlx-DhWwjtMw.png +0 -0
  27. package/dashboard/dist/assets/ollama-Bt9O-2K_.png +0 -0
  28. package/dashboard/dist/assets/openrouter-CsJ_bD5Q.ico +0 -0
  29. package/dashboard/dist/assets/playwright-BldPFZgC.ico +0 -0
  30. package/dashboard/dist/assets/qwen-xykkX0_y.png +0 -0
  31. package/dashboard/dist/assets/react-vendor-C7Sl8SE7.js +9 -0
  32. package/dashboard/dist/assets/router-DHISdpPk.js +3 -0
  33. package/dashboard/dist/assets/shared-BIP_4k4I.js +1 -0
  34. package/dashboard/dist/favicon.svg +28 -0
  35. package/dashboard/dist/index.html +17 -0
  36. package/dist/agent/acp-client.js +261 -0
  37. package/dist/agent/auto-update.js +432 -0
  38. package/dist/agent/await-resume.js +50 -0
  39. package/dist/agent/cli/auth.js +325 -0
  40. package/dist/agent/cli/catalog.js +40 -0
  41. package/dist/agent/cli/detector.js +136 -0
  42. package/dist/agent/cli/index.js +7 -0
  43. package/dist/agent/cli/registry.js +33 -0
  44. package/dist/agent/driver.js +39 -0
  45. package/dist/agent/drivers/claude-tui.js +2297 -0
  46. package/dist/agent/drivers/claude.js +2689 -0
  47. package/dist/agent/drivers/codex.js +2210 -0
  48. package/dist/agent/drivers/gemini.js +1059 -0
  49. package/dist/agent/drivers/hermes.js +795 -0
  50. package/dist/agent/goal.js +274 -0
  51. package/dist/agent/handover.js +130 -0
  52. package/dist/agent/images.js +355 -0
  53. package/dist/agent/index.js +50 -0
  54. package/dist/agent/mcp/bridge.js +791 -0
  55. package/dist/agent/mcp/extensions.js +637 -0
  56. package/dist/agent/mcp/oauth.js +353 -0
  57. package/dist/agent/mcp/registry.js +119 -0
  58. package/dist/agent/mcp/session-server.js +229 -0
  59. package/dist/agent/mcp/tools/ask-user.js +113 -0
  60. package/dist/agent/mcp/tools/await-resume.js +77 -0
  61. package/dist/agent/mcp/tools/goal.js +144 -0
  62. package/dist/agent/mcp/tools/types.js +12 -0
  63. package/dist/agent/mcp/tools/workspace.js +212 -0
  64. package/dist/agent/npm.js +31 -0
  65. package/dist/agent/session.js +1206 -0
  66. package/dist/agent/skill-installer.js +160 -0
  67. package/dist/agent/skills.js +257 -0
  68. package/dist/agent/stream.js +743 -0
  69. package/dist/agent/types.js +13 -0
  70. package/dist/agent/utils.js +687 -0
  71. package/dist/bot/bot.js +2499 -0
  72. package/dist/bot/command-ui.js +633 -0
  73. package/dist/bot/commands.js +513 -0
  74. package/dist/bot/headless-bot.js +36 -0
  75. package/dist/bot/host.js +192 -0
  76. package/dist/bot/human-loop.js +168 -0
  77. package/dist/bot/menu.js +48 -0
  78. package/dist/bot/orchestration.js +79 -0
  79. package/dist/bot/render-shared.js +309 -0
  80. package/dist/bot/session-hub.js +361 -0
  81. package/dist/bot/session-status.js +55 -0
  82. package/dist/bot/streaming.js +309 -0
  83. package/dist/browser-profile.js +579 -0
  84. package/dist/browser-supervisor.js +249 -0
  85. package/dist/catalog/cli-tools.js +421 -0
  86. package/dist/catalog/index.js +21 -0
  87. package/dist/catalog/local-models.js +94 -0
  88. package/dist/catalog/mcp-servers.js +315 -0
  89. package/dist/catalog/skill-repos.js +173 -0
  90. package/dist/channels/base.js +55 -0
  91. package/dist/channels/dingtalk/bot.js +549 -0
  92. package/dist/channels/dingtalk/channel.js +268 -0
  93. package/dist/channels/discord/bot.js +552 -0
  94. package/dist/channels/discord/channel.js +245 -0
  95. package/dist/channels/feishu/bot.js +1275 -0
  96. package/dist/channels/feishu/channel.js +911 -0
  97. package/dist/channels/feishu/markdown.js +91 -0
  98. package/dist/channels/feishu/render.js +619 -0
  99. package/dist/channels/health.js +109 -0
  100. package/dist/channels/slack/bot.js +554 -0
  101. package/dist/channels/slack/channel.js +283 -0
  102. package/dist/channels/states.js +6 -0
  103. package/dist/channels/telegram/bot.js +1310 -0
  104. package/dist/channels/telegram/channel.js +820 -0
  105. package/dist/channels/telegram/directory.js +111 -0
  106. package/dist/channels/telegram/live-preview.js +220 -0
  107. package/dist/channels/telegram/render.js +384 -0
  108. package/dist/channels/wecom/bot.js +558 -0
  109. package/dist/channels/wecom/channel.js +479 -0
  110. package/dist/channels/weixin/api.js +520 -0
  111. package/dist/channels/weixin/bot.js +1000 -0
  112. package/dist/channels/weixin/channel.js +222 -0
  113. package/dist/cli/autostart.js +262 -0
  114. package/dist/cli/channel-supervisor.js +313 -0
  115. package/dist/cli/channels.js +54 -0
  116. package/dist/cli/main.js +726 -0
  117. package/dist/cli/onboarding.js +227 -0
  118. package/dist/cli/run.js +308 -0
  119. package/dist/cli/setup-wizard.js +235 -0
  120. package/dist/core/config/runtime-config.js +201 -0
  121. package/dist/core/config/user-config.js +510 -0
  122. package/dist/core/config/validation.js +521 -0
  123. package/dist/core/constants.js +400 -0
  124. package/dist/core/git.js +145 -0
  125. package/dist/core/legacy-compat.js +60 -0
  126. package/dist/core/logging.js +101 -0
  127. package/dist/core/platform.js +59 -0
  128. package/dist/core/process-control.js +315 -0
  129. package/dist/core/secrets/index.js +42 -0
  130. package/dist/core/secrets/inline-seal.js +60 -0
  131. package/dist/core/secrets/ref.js +33 -0
  132. package/dist/core/secrets/resolver.js +65 -0
  133. package/dist/core/secrets/store.js +63 -0
  134. package/dist/core/utils.js +233 -0
  135. package/dist/core/version.js +15 -0
  136. package/dist/dashboard/platform.js +219 -0
  137. package/dist/dashboard/routes/agents.js +450 -0
  138. package/dist/dashboard/routes/cli.js +174 -0
  139. package/dist/dashboard/routes/config.js +523 -0
  140. package/dist/dashboard/routes/extensions.js +745 -0
  141. package/dist/dashboard/routes/local-models.js +290 -0
  142. package/dist/dashboard/routes/models.js +324 -0
  143. package/dist/dashboard/routes/sessions.js +838 -0
  144. package/dist/dashboard/runtime.js +410 -0
  145. package/dist/dashboard/server.js +237 -0
  146. package/dist/dashboard/session-control.js +347 -0
  147. package/dist/model/catalog.js +104 -0
  148. package/dist/model/index.js +20 -0
  149. package/dist/model/injector.js +272 -0
  150. package/dist/model/provider-models.js +112 -0
  151. package/dist/model/store.js +212 -0
  152. package/dist/model/types.js +13 -0
  153. package/dist/model/validation.js +203 -0
  154. package/package.json +82 -0
@@ -0,0 +1,1059 @@
1
+ /**
2
+ * driver-gemini.ts — Gemini CLI agent driver.
3
+ *
4
+ * Requires `gemini` CLI installed (https://github.com/google-gemini/gemini-cli).
5
+ * Stream protocol: spawns `gemini` with JSON output and parses stdout line-by-line.
6
+ */
7
+ import { registerDriver } from '../driver.js';
8
+ import fs from 'node:fs';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+ import { execSync } from 'node:child_process';
12
+ import { GEMINI_USAGE_TIMEOUTS, SESSION_RUNNING_THRESHOLD_MS } from '../../core/constants.js';
13
+ import { run, agentLog, appendSystemPrompt, pushRecentActivity, firstNonEmptyLine, shortValue, normalizeErrorMessage, sanitizeSessionUserPreviewText, emitSessionIdUpdate, listPikiloopSessions, mergeManagedAndNativeSessions, applyTurnWindow, stripInjectedPrompts, attachAgentImage, roundPercent, emptyUsage, Q, } from '../index.js';
14
+ import { getHome } from '../../core/platform.js';
15
+ // ---------------------------------------------------------------------------
16
+ // Command & parser
17
+ // ---------------------------------------------------------------------------
18
+ function hasGeminiFlag(args, names) {
19
+ if (!args?.length)
20
+ return false;
21
+ return args.some(arg => {
22
+ const trimmed = String(arg || '').trim();
23
+ if (!trimmed.startsWith('-'))
24
+ return false;
25
+ return names.some(name => trimmed === name || trimmed.startsWith(`${name}=`));
26
+ });
27
+ }
28
+ // Gemini CLI's -p mode is text-only — there's no flag for binary inputs. The
29
+ // CLI does, however, parse `@<path>` references in the prompt and inlines the
30
+ // file's content (text or image) into the model's context. We splice those
31
+ // references at the front of the prompt so attachments survive the trip.
32
+ export function buildGeminiPromptText(prompt, attachments) {
33
+ if (!attachments.length)
34
+ return prompt;
35
+ // Quote paths that contain spaces — gemini's tokenizer reads `@"..."` as a
36
+ // single reference. Plain paths can be left bare for cleaner display.
37
+ const refs = attachments.map(p => /\s/.test(p) ? `@"${p}"` : `@${p}`).join(' ');
38
+ return prompt ? `${refs}\n\n${prompt}` : refs;
39
+ }
40
+ function geminiCmd(o) {
41
+ const approvalMode = o.geminiApprovalMode || 'yolo';
42
+ const sandbox = typeof o.geminiSandbox === 'boolean' ? o.geminiSandbox : false;
43
+ const args = ['gemini', '--output-format', 'stream-json'];
44
+ if (o.geminiModel)
45
+ args.push('--model', o.geminiModel);
46
+ if (o.sessionId)
47
+ args.push('--resume', o.sessionId);
48
+ if (!hasGeminiFlag(o.geminiExtraArgs, ['--approval-mode', '--yolo', '-y'])) {
49
+ args.push('--approval-mode', approvalMode);
50
+ }
51
+ if (!hasGeminiFlag(o.geminiExtraArgs, ['--sandbox', '-s'])) {
52
+ args.push('--sandbox', String(sandbox));
53
+ }
54
+ if (o.geminiExtraArgs?.length)
55
+ args.push(...o.geminiExtraArgs);
56
+ // gemini's -p requires the prompt as its value (not via stdin)
57
+ const userPrompt = buildGeminiPromptText(o.prompt, o.attachments || []);
58
+ const promptText = o.geminiSystemInstruction
59
+ ? appendSystemPrompt(o.geminiSystemInstruction, userPrompt)
60
+ : userPrompt;
61
+ args.push('-p', promptText);
62
+ return args;
63
+ }
64
+ function geminiContextWindowFromModel(model) {
65
+ const id = typeof model === 'string' ? model.trim().toLowerCase() : '';
66
+ if (!id)
67
+ return null;
68
+ if (/^(auto-gemini-(2\.5|3)|gemini-(2\.5|3|3\.1)-)/.test(id))
69
+ return 1_048_576;
70
+ return null;
71
+ }
72
+ function geminiToolName(value) {
73
+ const name = typeof value === 'string' ? value.trim() : '';
74
+ return name || 'tool';
75
+ }
76
+ function geminiToolLabel(name) {
77
+ return name
78
+ .replace(/^mcp_/, '')
79
+ .replace(/^discovered_tool_/, '')
80
+ .replace(/_/g, ' ')
81
+ .replace(/\s+/g, ' ')
82
+ .trim() || 'tool';
83
+ }
84
+ function geminiToolSummary(name, parameters) {
85
+ const tool = geminiToolName(name);
86
+ const params = parameters && typeof parameters === 'object' ? parameters : {};
87
+ switch (tool) {
88
+ case 'read_file': {
89
+ const target = shortValue(params.file_path || params.path, 140);
90
+ return target ? `Read ${target}` : 'Read file';
91
+ }
92
+ case 'read_many_files': {
93
+ const include = shortValue(params.include || params.pattern, 120);
94
+ return include ? `Read files: ${include}` : 'Read files';
95
+ }
96
+ case 'write_file': {
97
+ const target = shortValue(params.file_path || params.path, 140);
98
+ return target ? `Write ${target}` : 'Write file';
99
+ }
100
+ case 'replace': {
101
+ const target = shortValue(params.file_path || params.path, 140);
102
+ return target ? `Edit ${target}` : 'Edit file';
103
+ }
104
+ case 'list_directory': {
105
+ const dir = shortValue(params.dir_path || params.path, 120);
106
+ return dir ? `List files: ${dir}` : 'List files';
107
+ }
108
+ case 'glob': {
109
+ const pattern = shortValue(params.pattern || params.glob, 120);
110
+ return pattern ? `Find files: ${pattern}` : 'Find files';
111
+ }
112
+ case 'grep_search':
113
+ case 'search_file_content': {
114
+ const pattern = shortValue(params.pattern || params.query, 120);
115
+ return pattern ? `Search text: ${pattern}` : 'Search text';
116
+ }
117
+ case 'run_shell_command': {
118
+ const command = shortValue(params.command, 120);
119
+ return command ? `Run shell: ${command}` : 'Run shell';
120
+ }
121
+ case 'web_fetch': {
122
+ const target = shortValue(params.url || params.prompt, 120);
123
+ return target ? `Fetch ${target}` : 'Fetch web page';
124
+ }
125
+ case 'google_web_search': {
126
+ const query = shortValue(params.query, 120);
127
+ return query ? `Search web: ${query}` : 'Search web';
128
+ }
129
+ case 'write_todos': return 'Update todo list';
130
+ case 'save_memory': return 'Save memory';
131
+ case 'ask_user': return 'Request user input';
132
+ case 'activate_skill': {
133
+ const skill = shortValue(params.name, 80);
134
+ return skill ? `Activate skill: ${skill}` : 'Activate skill';
135
+ }
136
+ case 'get_internal_docs': {
137
+ const target = shortValue(params.path, 120);
138
+ return target ? `Read docs: ${target}` : 'Read docs';
139
+ }
140
+ case 'enter_plan_mode': return 'Enter plan mode';
141
+ case 'exit_plan_mode': return 'Exit plan mode';
142
+ default: {
143
+ const detail = shortValue(params.file_path
144
+ || params.path
145
+ || params.dir_path
146
+ || params.pattern
147
+ || params.query
148
+ || params.command
149
+ || params.url
150
+ || params.name, 120);
151
+ const label = shortValue(geminiToolLabel(tool), 80);
152
+ return detail ? `Use ${label}: ${detail}` : `Use ${label}`;
153
+ }
154
+ }
155
+ }
156
+ function geminiToolResultSummary(tool, ev) {
157
+ const fallbackSummary = geminiToolSummary(tool?.name || ev.tool_name || ev.name || ev.tool, ev.parameters || ev.args || ev.input || {});
158
+ const summary = tool?.summary || fallbackSummary;
159
+ const detail = shortValue(firstNonEmptyLine(normalizeErrorMessage(ev.error)
160
+ || ev.output
161
+ || ev.message
162
+ || ''), 120);
163
+ if (ev.status === 'error')
164
+ return detail ? `${summary} failed: ${detail}` : `${summary} failed`;
165
+ return detail ? `${summary} -> ${detail}` : `${summary} done`;
166
+ }
167
+ function geminiParse(ev, s) {
168
+ const t = ev.type || '';
169
+ // init event: {"type":"init","session_id":"...","model":"..."}
170
+ if (t === 'init') {
171
+ emitSessionIdUpdate(s, ev.session_id);
172
+ s.model = ev.model ?? s.model;
173
+ s.contextWindow = geminiContextWindowFromModel(s.model) ?? s.contextWindow;
174
+ // Gemini's stream-json drops `thought` parts and every `agent_*`/`tool_update`
175
+ // event, so between init and the first tool_use/message there's nothing to
176
+ // surface — easily 10–30s on Gemini 3 Pro with HIGH thinking, longer when
177
+ // 429 backoffs kick in. Plant a sentinel so the IM/dashboard activity area
178
+ // shows progress instead of staying blank.
179
+ pushRecentActivity(s.recentActivity, 'Thinking...');
180
+ s.activity = s.recentActivity.join('\n');
181
+ }
182
+ // message delta: {"type":"message","role":"assistant","content":"...","delta":true}
183
+ if (t === 'message' && ev.role === 'assistant') {
184
+ if (ev.delta)
185
+ s.text += ev.content || '';
186
+ else if (!s.text.trim())
187
+ s.text = ev.content || '';
188
+ }
189
+ if (t === 'tool_use' || t === 'tool_call') {
190
+ const name = geminiToolName(ev.tool_name || ev.name || ev.tool);
191
+ const summary = geminiToolSummary(name, ev.parameters || ev.args || ev.input || {});
192
+ const toolId = String(ev.tool_id || ev.id || '').trim();
193
+ if (toolId)
194
+ s.geminiToolsById.set(toolId, { name, summary });
195
+ pushRecentActivity(s.recentActivity, summary);
196
+ s.activity = s.recentActivity.join('\n');
197
+ }
198
+ if (t === 'tool_result') {
199
+ const toolId = String(ev.tool_id || ev.id || '').trim();
200
+ const tool = toolId ? s.geminiToolsById.get(toolId) : undefined;
201
+ pushRecentActivity(s.recentActivity, geminiToolResultSummary(tool, ev));
202
+ s.activity = s.recentActivity.join('\n');
203
+ }
204
+ if (t === 'error') {
205
+ const message = normalizeErrorMessage(ev.message || ev.error) || 'Gemini reported an error';
206
+ if (ev.severity === 'error') {
207
+ s.errors = [...(s.errors || []), message];
208
+ }
209
+ else {
210
+ pushRecentActivity(s.recentActivity, message);
211
+ s.activity = s.recentActivity.join('\n');
212
+ }
213
+ }
214
+ // result event: {"type":"result","status":"success","stats":{...}}
215
+ if (t === 'result') {
216
+ emitSessionIdUpdate(s, ev.session_id);
217
+ if (ev.status === 'error' || ev.status === 'failure') {
218
+ const message = normalizeErrorMessage(ev.error)
219
+ || normalizeErrorMessage(ev.errors)
220
+ || normalizeErrorMessage(ev.message)
221
+ || `Gemini returned status: ${ev.status}`;
222
+ s.errors = [message];
223
+ }
224
+ s.stopReason = ev.status === 'success' ? 'end_turn' : ev.status;
225
+ const u = ev.stats;
226
+ if (u) {
227
+ s.inputTokens = u.input_tokens ?? u.input ?? s.inputTokens;
228
+ s.outputTokens = u.output_tokens ?? u.output ?? s.outputTokens;
229
+ s.cachedInputTokens = u.cached ?? s.cachedInputTokens;
230
+ // Gemini's `input_tokens` is the full prompt size (cached portion is
231
+ // already a subset of it). Use it directly as the context-window
232
+ // occupancy — adding `cached` would double-count.
233
+ if (s.inputTokens != null)
234
+ s.contextUsedTokens = s.inputTokens;
235
+ }
236
+ s.contextWindow = geminiContextWindowFromModel(s.model) ?? s.contextWindow;
237
+ }
238
+ }
239
+ // Gemini-cli does an exponential backoff on 429s and other transient errors
240
+ // without emitting any stream-json event — only stderr gets a line like
241
+ // `Attempt 1 failed with status 429. Retrying with backoff...`. Surface those
242
+ // lines as activity so users don't see a frozen UI during MODEL_CAPACITY_EXHAUSTED.
243
+ const GEMINI_RETRY_RE = /^Attempt\s+(\d+)\s+failed\s+with\s+status\s+(\d+)/i;
244
+ function geminiParseStderrLine(line, s) {
245
+ const m = GEMINI_RETRY_RE.exec(line);
246
+ if (!m)
247
+ return;
248
+ const attempt = m[1];
249
+ const status = m[2];
250
+ const reason = status === '429' ? 'rate limit / capacity exhausted'
251
+ : status === '503' ? 'service unavailable'
252
+ : `status ${status}`;
253
+ pushRecentActivity(s.recentActivity, `Retrying after ${reason} (attempt ${attempt})`);
254
+ s.activity = s.recentActivity.join('\n');
255
+ }
256
+ // ---------------------------------------------------------------------------
257
+ // Thinking effort overlay
258
+ //
259
+ // Gemini CLI exposes thinking via two knobs depending on model family:
260
+ // - Gemini 3.x: thinkingLevel: "LOW" | "HIGH"
261
+ // - Gemini 2.5: thinkingBudget: number (0=off, 8192=default, -1=dynamic)
262
+ // There is no CLI flag — the only place the CLI reads them is settings.json
263
+ // under `agents.<chat-base*>.modelConfig.generateContentConfig.thinkingConfig`.
264
+ //
265
+ // We don't want to mutate the user's ~/.gemini/settings.json, so for streams
266
+ // where an effort is set we materialise a fake $HOME via GEMINI_CLI_HOME and
267
+ // place a synthetic `.gemini/` inside it: symlinks for everything in the
268
+ // real ~/.gemini/ (oauth, projects, history, tmp, …) plus our merged
269
+ // settings.json. Note that gemini-cli reads GEMINI_CLI_HOME as the *parent*
270
+ // of `.gemini/`, not as `.gemini/` itself — getting that wrong makes gemini
271
+ // fail with "Please set an Auth method" because it can't find any creds.
272
+ // ---------------------------------------------------------------------------
273
+ function geminiEffortOverlay(effort) {
274
+ const value = String(effort || '').trim().toLowerCase();
275
+ if (!value)
276
+ return null;
277
+ let level3;
278
+ let budget25;
279
+ if (value === 'low' || value === 'minimal') {
280
+ level3 = 'LOW';
281
+ budget25 = 512;
282
+ }
283
+ else if (value === 'medium') {
284
+ level3 = 'HIGH';
285
+ budget25 = 8192;
286
+ }
287
+ else {
288
+ level3 = 'HIGH';
289
+ budget25 = -1;
290
+ }
291
+ return {
292
+ 'chat-base-3': {
293
+ modelConfig: { generateContentConfig: { thinkingConfig: { thinkingLevel: level3 } } },
294
+ },
295
+ 'chat-base-2.5': {
296
+ modelConfig: { generateContentConfig: { thinkingConfig: { thinkingBudget: budget25 } } },
297
+ },
298
+ };
299
+ }
300
+ function deepMergeAgents(base, overlay) {
301
+ const out = base && typeof base === 'object' && !Array.isArray(base) ? { ...base } : {};
302
+ for (const key of Object.keys(overlay)) {
303
+ out[key] = mergePlainObjects(out[key], overlay[key]);
304
+ }
305
+ return out;
306
+ }
307
+ function mergePlainObjects(a, b) {
308
+ if (b === undefined)
309
+ return a;
310
+ if (a === undefined || a === null || typeof a !== 'object' || Array.isArray(a))
311
+ return b;
312
+ if (typeof b !== 'object' || Array.isArray(b))
313
+ return b;
314
+ const out = { ...a };
315
+ for (const key of Object.keys(b))
316
+ out[key] = mergePlainObjects(a[key], b[key]);
317
+ return out;
318
+ }
319
+ function prepareGeminiHomeOverlay(opts) {
320
+ const effortOverrides = geminiEffortOverlay(opts.effort);
321
+ const needsFileFilterBypass = opts.hasAttachments;
322
+ if (!effortOverrides && !needsFileFilterBypass)
323
+ return null;
324
+ const home = getHome();
325
+ if (!home)
326
+ return null;
327
+ const userGeminiDir = path.join(home, '.gemini');
328
+ if (!fs.existsSync(userGeminiDir))
329
+ return null;
330
+ let overlayHome;
331
+ try {
332
+ overlayHome = fs.mkdtempSync(path.join(os.tmpdir(), 'pikiloop-gemini-'));
333
+ }
334
+ catch {
335
+ return null;
336
+ }
337
+ const overlayGeminiDir = path.join(overlayHome, '.gemini');
338
+ try {
339
+ fs.mkdirSync(overlayGeminiDir, { recursive: true });
340
+ }
341
+ catch {
342
+ try {
343
+ fs.rmSync(overlayHome, { recursive: true, force: true });
344
+ }
345
+ catch { }
346
+ return null;
347
+ }
348
+ // Symlink every entry in ~/.gemini except settings.json so OAuth, projects,
349
+ // history, tmp/, etc. all stay shared with the user's real config.
350
+ try {
351
+ for (const entry of fs.readdirSync(userGeminiDir, { withFileTypes: true })) {
352
+ if (entry.name === 'settings.json')
353
+ continue;
354
+ try {
355
+ fs.symlinkSync(path.join(userGeminiDir, entry.name), path.join(overlayGeminiDir, entry.name));
356
+ }
357
+ catch { /* ignore individual symlink failures */ }
358
+ }
359
+ }
360
+ catch { /* readdir failure → fall through with whatever we managed */ }
361
+ let userSettings = {};
362
+ const userSettingsPath = path.join(userGeminiDir, 'settings.json');
363
+ try {
364
+ if (fs.existsSync(userSettingsPath)) {
365
+ userSettings = JSON.parse(fs.readFileSync(userSettingsPath, 'utf-8'));
366
+ }
367
+ }
368
+ catch { /* malformed user settings — start fresh */ }
369
+ const merged = { ...userSettings };
370
+ if (effortOverrides) {
371
+ merged.agents = deepMergeAgents(userSettings.agents, effortOverrides);
372
+ }
373
+ if (needsFileFilterBypass) {
374
+ const baseContext = userSettings.context && typeof userSettings.context === 'object' && !Array.isArray(userSettings.context)
375
+ ? userSettings.context : {};
376
+ const baseFileFiltering = baseContext.fileFiltering && typeof baseContext.fileFiltering === 'object' && !Array.isArray(baseContext.fileFiltering)
377
+ ? baseContext.fileFiltering : {};
378
+ merged.context = {
379
+ ...baseContext,
380
+ fileFiltering: {
381
+ ...baseFileFiltering,
382
+ respectGitIgnore: false,
383
+ respectGeminiIgnore: false,
384
+ },
385
+ };
386
+ }
387
+ try {
388
+ fs.writeFileSync(path.join(overlayGeminiDir, 'settings.json'), JSON.stringify(merged, null, 2));
389
+ }
390
+ catch {
391
+ try {
392
+ fs.rmSync(overlayHome, { recursive: true, force: true });
393
+ }
394
+ catch { }
395
+ return null;
396
+ }
397
+ return {
398
+ homeDir: overlayHome,
399
+ cleanup: () => { try {
400
+ fs.rmSync(overlayHome, { recursive: true, force: true });
401
+ }
402
+ catch { } },
403
+ };
404
+ }
405
+ // ---------------------------------------------------------------------------
406
+ // Stream
407
+ // ---------------------------------------------------------------------------
408
+ export async function doGeminiStream(opts) {
409
+ // Prompt is passed as -p argument; send empty stdin so run() doesn't duplicate it
410
+ const overlay = prepareGeminiHomeOverlay({
411
+ effort: opts.thinkingEffort,
412
+ hasAttachments: (opts.attachments?.length ?? 0) > 0,
413
+ });
414
+ const extraEnv = overlay
415
+ ? { ...(opts.extraEnv || {}), GEMINI_CLI_HOME: overlay.homeDir }
416
+ : opts.extraEnv;
417
+ const streamOpts = { ...opts, _stdinOverride: '', extraEnv };
418
+ try {
419
+ return await run(geminiCmd(opts), streamOpts, geminiParse, geminiParseStderrLine);
420
+ }
421
+ finally {
422
+ overlay?.cleanup();
423
+ }
424
+ }
425
+ // ---------------------------------------------------------------------------
426
+ // Sessions / Tail
427
+ // ---------------------------------------------------------------------------
428
+ /** Resolve Gemini project name for a workdir from ~/.gemini/projects.json */
429
+ function geminiProjectName(workdir) {
430
+ const home = getHome();
431
+ if (!home)
432
+ return null;
433
+ const projectsPath = path.join(home, '.gemini', 'projects.json');
434
+ try {
435
+ const data = JSON.parse(fs.readFileSync(projectsPath, 'utf8'));
436
+ const projects = data?.projects;
437
+ if (!projects || typeof projects !== 'object')
438
+ return null;
439
+ const resolved = path.resolve(workdir);
440
+ // Exact match first, then check entries
441
+ if (projects[resolved])
442
+ return projects[resolved];
443
+ for (const [dir, name] of Object.entries(projects)) {
444
+ if (path.resolve(dir) === resolved)
445
+ return name;
446
+ }
447
+ }
448
+ catch { /* skip */ }
449
+ return null;
450
+ }
451
+ function geminiChatsDir(workdir) {
452
+ const home = getHome();
453
+ if (!home)
454
+ return null;
455
+ const projectName = geminiProjectName(workdir);
456
+ if (!projectName)
457
+ return null;
458
+ return path.join(home, '.gemini', 'tmp', projectName, 'chats');
459
+ }
460
+ function extractGeminiText(content) {
461
+ if (typeof content === 'string')
462
+ return content.trim();
463
+ if (!Array.isArray(content))
464
+ return '';
465
+ const parts = [];
466
+ for (const block of content) {
467
+ if (typeof block === 'string') {
468
+ if (block.trim())
469
+ parts.push(block.trim());
470
+ continue;
471
+ }
472
+ const text = typeof block?.text === 'string' ? block.text.trim() : '';
473
+ if (text)
474
+ parts.push(text);
475
+ }
476
+ return parts.join('\n').trim();
477
+ }
478
+ // Gemini's -p mode is text-only, so pikiloop concatenates its system-prompt
479
+ // blocks ([Browser Automation], [Artifact Return], …) onto the user's prompt
480
+ // before invoking the CLI. That means the JSONL "user" message we read back
481
+ // later contains those orchestrator-injected blocks AND the gemini-CLI-emitted
482
+ // `--- Content from referenced files ---` markers it appends when expanding
483
+ // `@<path>` references. Both are noise for the dashboard / IM render path —
484
+ // the helpers below strip them so the displayed user bubble matches what the
485
+ // human actually typed, and surface staged image attachments as image blocks
486
+ // instead of raw `@<path>` text.
487
+ const GEMINI_SYSTEM_BLOCK_SENTINELS = [
488
+ '[Artifact Return]',
489
+ '[Asking the user]',
490
+ '[Browser Automation]',
491
+ '[Session Workspace]',
492
+ ];
493
+ const GEMINI_REFERENCED_FILES_BLOCK_RE = /\n*--- Content from referenced files ---[\s\S]*?--- End of content ---\n*/g;
494
+ const GEMINI_FILE_REF_RE = /(^|\s)@(?:"([^"]+)"|([^\s"@]+))/g;
495
+ function stripGeminiSystemPreamble(text) {
496
+ let cur = text.replace(/^\s+/, '');
497
+ while (true) {
498
+ const sentinel = GEMINI_SYSTEM_BLOCK_SENTINELS.find(s => cur.startsWith(s));
499
+ if (!sentinel)
500
+ break;
501
+ const blockEnd = cur.indexOf('\n\n');
502
+ if (blockEnd < 0)
503
+ return '';
504
+ cur = cur.slice(blockEnd + 2).replace(/^\s+/, '');
505
+ }
506
+ return cur;
507
+ }
508
+ function cleanGeminiUserText(rawText) {
509
+ if (!rawText)
510
+ return '';
511
+ let text = stripInjectedPrompts(rawText);
512
+ text = stripGeminiSystemPreamble(text);
513
+ text = text.replace(GEMINI_REFERENCED_FILES_BLOCK_RE, '\n');
514
+ return text.trim();
515
+ }
516
+ /**
517
+ * Build a (text, image blocks) pair for a rendered user bubble. `@<path>`
518
+ * references that resolve to readable image files are lifted into image
519
+ * blocks; refs that don't resolve are left in the text so the user can still
520
+ * see what they wrote.
521
+ */
522
+ function buildGeminiUserMessageContent(rawText, workdir) {
523
+ const cleaned = cleanGeminiUserText(rawText);
524
+ if (!cleaned)
525
+ return { text: '', blocks: [] };
526
+ const blocks = [];
527
+ const textOnly = cleaned.replace(GEMINI_FILE_REF_RE, (match, lead, quoted, bare) => {
528
+ const ref = String(quoted || bare || '').trim();
529
+ if (!ref)
530
+ return match;
531
+ const abs = path.isAbsolute(ref) ? ref : path.resolve(workdir, ref);
532
+ const block = attachAgentImage({ imagePath: abs });
533
+ if (block) {
534
+ blocks.push(block);
535
+ return lead || '';
536
+ }
537
+ return match;
538
+ });
539
+ return { text: textOnly.replace(/\n{3,}/g, '\n\n').trim(), blocks };
540
+ }
541
+ /** Drop the attachment `@<path>` refs entirely so they don't surface as raw
542
+ * paths in plain-text contexts (tail snippets, sidebar previews). Newlines
543
+ * in the surrounding prose are preserved. */
544
+ function dropGeminiFileRefs(text) {
545
+ return text.replace(GEMINI_FILE_REF_RE, '$1');
546
+ }
547
+ /** Single-line variant for session list titles where the bubble shape is a
548
+ * one-liner — collapses every whitespace run to a single space. */
549
+ function flattenGeminiUserText(rawText) {
550
+ return dropGeminiFileRefs(cleanGeminiUserText(rawText)).replace(/\s+/g, ' ').trim();
551
+ }
552
+ function normalizeGeminiSessionTitle(value) {
553
+ const text = String(value || '').replace(/\s+/g, ' ').trim();
554
+ if (!text)
555
+ return null;
556
+ return text.length <= 120 ? text : `${text.slice(0, 117).trimEnd()}...`;
557
+ }
558
+ function findGeminiSessionFile(workdir, sessionId) {
559
+ const chatsDir = geminiChatsDir(workdir);
560
+ if (!chatsDir || !fs.existsSync(chatsDir))
561
+ return null;
562
+ let entries;
563
+ try {
564
+ entries = fs.readdirSync(chatsDir, { withFileTypes: true });
565
+ }
566
+ catch {
567
+ return null;
568
+ }
569
+ for (const entry of entries) {
570
+ if (!entry.isFile() || !entry.name.startsWith('session-'))
571
+ continue;
572
+ if (!entry.name.endsWith('.json') && !entry.name.endsWith('.jsonl'))
573
+ continue;
574
+ const filePath = path.join(chatsDir, entry.name);
575
+ try {
576
+ const data = loadGeminiSessionData(filePath);
577
+ if (data?.sessionId === sessionId)
578
+ return filePath;
579
+ }
580
+ catch { /* skip */ }
581
+ }
582
+ return null;
583
+ }
584
+ function loadGeminiSessionData(filePath) {
585
+ try {
586
+ const content = fs.readFileSync(filePath, 'utf8');
587
+ if (filePath.endsWith('.json'))
588
+ return JSON.parse(content);
589
+ // JSONL format: first line is metadata, subsequent lines are messages or $set updates
590
+ const lines = content.split('\n');
591
+ let data = {};
592
+ const messages = [];
593
+ for (const line of lines) {
594
+ if (!line.trim() || line[0] !== '{')
595
+ continue;
596
+ try {
597
+ const obj = JSON.parse(line);
598
+ if (obj.sessionId && !data.sessionId) {
599
+ data = { ...obj };
600
+ }
601
+ else if (obj.$set) {
602
+ if (obj.$set.lastUpdated)
603
+ data.lastUpdated = obj.$set.lastUpdated;
604
+ }
605
+ else if (obj.type === 'user' || obj.type === 'gemini' || obj.type === 'model' || obj.type === 'assistant') {
606
+ messages.push(obj);
607
+ }
608
+ }
609
+ catch { /* skip */ }
610
+ }
611
+ data.messages = messages;
612
+ return data;
613
+ }
614
+ catch {
615
+ return null;
616
+ }
617
+ }
618
+ // Per-file cache of the derived fields. getNativeGeminiSessionsFromFiles read +
619
+ // JSON-parsed every chat file's full contents on every list request AND per
620
+ // workspace×agent in the overview fan-out. Keyed by (mtime,size) so unchanged
621
+ // chats are never re-read; `running` depends on Date.now() so it's recomputed
622
+ // per call. Stores only the small derived fields, never the full messages array.
623
+ const nativeGeminiContentCache = new Map();
624
+ function readNativeGeminiContent(filePath) {
625
+ const data = loadGeminiSessionData(filePath);
626
+ if (!data?.sessionId)
627
+ return null;
628
+ // Gemini CLI writes stub session files for internal bookkeeping — e.g.
629
+ // `sessionId: "a2a-server"` for its built-in a2a server, plus abandoned
630
+ // UUID-named sessions that never received a turn. Both share the same shape:
631
+ // metadata only, no `messages` array. Nothing to render, so skip them.
632
+ const messages = Array.isArray(data.messages) ? data.messages : [];
633
+ if (messages.length === 0)
634
+ return null;
635
+ // Extract title from first user message + last Q&A from tail.
636
+ let title = null;
637
+ let lastQuestion = null;
638
+ let lastAnswer = null;
639
+ let lastMessageText = null;
640
+ for (const msg of messages) {
641
+ if (msg.type === 'user') {
642
+ const text = sanitizeSessionUserPreviewText(flattenGeminiUserText(extractGeminiText(msg.content)));
643
+ if (!title)
644
+ title = normalizeGeminiSessionTitle(text);
645
+ if (text) {
646
+ lastQuestion = shortValue(text, 500);
647
+ lastMessageText = shortValue(text, 500);
648
+ }
649
+ }
650
+ else if (msg.type === 'model' || msg.type === 'assistant' || msg.type === 'gemini') {
651
+ const text = extractGeminiText(msg.content);
652
+ if (text) {
653
+ lastAnswer = shortValue(text, 500);
654
+ lastMessageText = shortValue(text, 500);
655
+ }
656
+ }
657
+ }
658
+ const numTurns = messages.filter((m) => m.type === 'user' && flattenGeminiUserText(extractGeminiText(m.content))).length;
659
+ return {
660
+ sessionId: String(data.sessionId),
661
+ title,
662
+ createdAt: data.startTime || data.createdAt || null,
663
+ updatedAt: data.lastUpdated || data.startTime || data.createdAt || null,
664
+ lastUpdated: data.lastUpdated || null,
665
+ lastQuestion,
666
+ lastAnswer,
667
+ lastMessageText,
668
+ numTurns: numTurns || null,
669
+ };
670
+ }
671
+ /** Read native Gemini CLI sessions from ~/.gemini/tmp/{projectName}/chats/ */
672
+ function getNativeGeminiSessionsFromFiles(workdir) {
673
+ const chatsDir = geminiChatsDir(workdir);
674
+ if (!chatsDir || !fs.existsSync(chatsDir))
675
+ return [];
676
+ let entries;
677
+ try {
678
+ entries = fs.readdirSync(chatsDir, { withFileTypes: true });
679
+ }
680
+ catch {
681
+ return [];
682
+ }
683
+ const sessionsById = new Map();
684
+ for (const entry of entries) {
685
+ if (!entry.isFile() || !entry.name.startsWith('session-'))
686
+ continue;
687
+ if (!entry.name.endsWith('.json') && !entry.name.endsWith('.jsonl'))
688
+ continue;
689
+ const filePath = path.join(chatsDir, entry.name);
690
+ let stat;
691
+ try {
692
+ stat = fs.statSync(filePath);
693
+ }
694
+ catch {
695
+ continue;
696
+ }
697
+ let cached = nativeGeminiContentCache.get(filePath);
698
+ if (!cached || cached.mtimeMs !== stat.mtimeMs || cached.size !== stat.size) {
699
+ cached = { mtimeMs: stat.mtimeMs, size: stat.size, content: readNativeGeminiContent(filePath) };
700
+ nativeGeminiContentCache.set(filePath, cached);
701
+ }
702
+ const content = cached.content;
703
+ if (!content)
704
+ continue;
705
+ // If we already saw this sessionId, only replace it if this file is newer.
706
+ const existing = sessionsById.get(content.sessionId);
707
+ if (existing && content.updatedAt && existing.runUpdatedAt && Date.parse(content.updatedAt) <= Date.parse(existing.runUpdatedAt)) {
708
+ continue;
709
+ }
710
+ const running = content.lastUpdated ? Date.now() - Date.parse(content.lastUpdated) < SESSION_RUNNING_THRESHOLD_MS : false;
711
+ sessionsById.set(content.sessionId, {
712
+ sessionId: content.sessionId,
713
+ agent: 'gemini',
714
+ workdir,
715
+ workspacePath: null,
716
+ model: null,
717
+ createdAt: content.createdAt,
718
+ title: content.title,
719
+ running,
720
+ runState: running ? 'running' : 'completed',
721
+ runDetail: null,
722
+ runUpdatedAt: content.updatedAt,
723
+ classification: null,
724
+ userStatus: null,
725
+ userNote: null,
726
+ lastQuestion: content.lastQuestion,
727
+ lastAnswer: content.lastAnswer,
728
+ lastMessageText: content.lastMessageText,
729
+ migratedFrom: null,
730
+ migratedTo: null,
731
+ linkedSessions: [],
732
+ numTurns: content.numTurns,
733
+ });
734
+ }
735
+ return [...sessionsById.values()];
736
+ }
737
+ function getNativeGeminiSessions(workdir) {
738
+ return getNativeGeminiSessionsFromFiles(workdir);
739
+ }
740
+ function getGeminiSessions(workdir, limit) {
741
+ const resolvedWorkdir = path.resolve(workdir);
742
+ // Merge pikiloop-tracked sessions with native Gemini sessions
743
+ const pikiloopSessions = listPikiloopSessions(resolvedWorkdir, 'gemini').map(record => ({
744
+ sessionId: record.sessionId,
745
+ agent: 'gemini',
746
+ workdir: record.workdir,
747
+ workspacePath: record.workspacePath,
748
+ threadId: record.threadId,
749
+ model: record.model,
750
+ createdAt: record.createdAt,
751
+ title: record.title,
752
+ running: record.runState === 'running',
753
+ runState: record.runState,
754
+ runDetail: record.runDetail,
755
+ runUpdatedAt: record.runUpdatedAt,
756
+ runPid: record.runPid,
757
+ classification: record.classification,
758
+ userStatus: record.userStatus,
759
+ userNote: record.userNote,
760
+ lastQuestion: record.lastQuestion,
761
+ lastAnswer: record.lastAnswer,
762
+ lastMessageText: record.lastMessageText,
763
+ migratedFrom: record.migratedFrom,
764
+ migratedTo: record.migratedTo,
765
+ linkedSessions: record.linkedSessions,
766
+ numTurns: record.numTurns ?? null,
767
+ }));
768
+ const nativeSessions = getNativeGeminiSessions(resolvedWorkdir);
769
+ const merged = mergeManagedAndNativeSessions(pikiloopSessions, nativeSessions);
770
+ const sessions = typeof limit === 'number' ? merged.slice(0, limit) : merged;
771
+ const projectName = geminiProjectName(resolvedWorkdir);
772
+ const chatsDir = projectName ? geminiChatsDir(resolvedWorkdir) || '' : '';
773
+ agentLog(`[sessions:gemini] workdir=${resolvedWorkdir} projectName=${projectName || '(none)'} chatsDir=${chatsDir || '(none)'} ` +
774
+ `chatsDirExists=${chatsDir ? fs.existsSync(chatsDir) : false} pikiloop=${pikiloopSessions.length} native=${nativeSessions.length} merged=${sessions.length}`);
775
+ return { ok: true, sessions, error: null };
776
+ }
777
+ function getGeminiSessionTail(opts) {
778
+ const limit = opts.limit ?? 4;
779
+ const filePath = findGeminiSessionFile(opts.workdir, opts.sessionId);
780
+ if (!filePath)
781
+ return { ok: false, messages: [], error: 'Session file not found' };
782
+ try {
783
+ const data = loadGeminiSessionData(filePath);
784
+ const messages = Array.isArray(data?.messages) ? data.messages : [];
785
+ const allMsgs = [];
786
+ for (const msg of messages) {
787
+ const type = typeof msg?.type === 'string' ? msg.type.trim().toLowerCase() : '';
788
+ const role = type === 'user' ? 'user' : (type === 'gemini' || type === 'model' || type === 'assistant') ? 'assistant' : null;
789
+ if (!role)
790
+ continue;
791
+ const rawText = extractGeminiText(msg?.content);
792
+ const text = role === 'user' ? dropGeminiFileRefs(cleanGeminiUserText(rawText)) : rawText;
793
+ if (text)
794
+ allMsgs.push({ role, text });
795
+ }
796
+ return { ok: true, messages: allMsgs.slice(-limit), error: null };
797
+ }
798
+ catch (e) {
799
+ return { ok: false, messages: [], error: e.message };
800
+ }
801
+ }
802
+ // ---------------------------------------------------------------------------
803
+ // Session messages (full content)
804
+ // ---------------------------------------------------------------------------
805
+ function getGeminiSessionMessages(opts) {
806
+ const filePath = findGeminiSessionFile(opts.workdir, opts.sessionId);
807
+ if (!filePath)
808
+ return { ok: false, messages: [], totalTurns: 0, error: 'Session file not found' };
809
+ try {
810
+ const data = loadGeminiSessionData(filePath);
811
+ const messages = Array.isArray(data?.messages) ? data.messages : [];
812
+ const allMsgs = [];
813
+ const richMsgs = [];
814
+ for (const msg of messages) {
815
+ const type = typeof msg?.type === 'string' ? msg.type.trim().toLowerCase() : '';
816
+ const role = type === 'user' ? 'user' : (type === 'gemini' || type === 'model' || type === 'assistant') ? 'assistant' : null;
817
+ if (!role)
818
+ continue;
819
+ const rawText = extractGeminiText(msg?.content);
820
+ if (role === 'user') {
821
+ const { text, blocks: imageBlocks } = buildGeminiUserMessageContent(rawText, opts.workdir);
822
+ if (!text && !imageBlocks.length)
823
+ continue;
824
+ allMsgs.push({ role, text });
825
+ const blocks = [];
826
+ if (text)
827
+ blocks.push({ type: 'text', content: text });
828
+ blocks.push(...imageBlocks);
829
+ richMsgs.push({ role, text, blocks });
830
+ }
831
+ else {
832
+ if (!rawText)
833
+ continue;
834
+ allMsgs.push({ role, text: rawText });
835
+ richMsgs.push({ role, text: rawText, blocks: [{ type: 'text', content: rawText }] });
836
+ }
837
+ }
838
+ return applyTurnWindow(allMsgs, opts, opts.rich ? richMsgs : undefined);
839
+ }
840
+ catch (e) {
841
+ return { ok: false, messages: [], totalTurns: 0, error: e.message };
842
+ }
843
+ }
844
+ // ---------------------------------------------------------------------------
845
+ // Models — static list for now, can be extended with `gemini models list`
846
+ // ---------------------------------------------------------------------------
847
+ // Model IDs from gemini-cli-core (no CLI command to list them dynamically)
848
+ const GEMINI_MODELS = [
849
+ { id: 'auto-gemini-3', alias: 'auto-3' },
850
+ { id: 'auto-gemini-2.5', alias: 'auto' },
851
+ { id: 'gemini-3.1-pro-preview', alias: '3.1-pro' },
852
+ { id: 'gemini-3-pro-preview', alias: '3-pro' },
853
+ { id: 'gemini-3-flash-preview', alias: '3-flash' },
854
+ { id: 'gemini-2.5-pro', alias: 'pro' },
855
+ { id: 'gemini-2.5-flash', alias: 'flash' },
856
+ { id: 'gemini-2.5-flash-lite', alias: 'flash-lite' },
857
+ ];
858
+ // ---------------------------------------------------------------------------
859
+ // Usage
860
+ // ---------------------------------------------------------------------------
861
+ const GEMINI_USAGE_TIMEOUT_MS = GEMINI_USAGE_TIMEOUTS.request;
862
+ const GEMINI_USAGE_URL = 'https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota';
863
+ let lastGeminiUsage = null;
864
+ function cachedGeminiUsage(error) {
865
+ return lastGeminiUsage?.ok ? lastGeminiUsage : emptyUsage('gemini', error);
866
+ }
867
+ function getGeminiOAuthToken() {
868
+ const home = getHome();
869
+ if (!home)
870
+ return null;
871
+ const credsPath = path.join(home, '.gemini', 'oauth_creds.json');
872
+ try {
873
+ const raw = fs.readFileSync(credsPath, 'utf-8').trim();
874
+ if (!raw || raw[0] !== '{')
875
+ return null;
876
+ const parsed = JSON.parse(raw);
877
+ const token = typeof parsed?.access_token === 'string' ? parsed.access_token.trim() : '';
878
+ return token || null;
879
+ }
880
+ catch {
881
+ return null;
882
+ }
883
+ }
884
+ function geminiUsageLabel(modelId) {
885
+ const raw = typeof modelId === 'string' ? modelId.trim() : '';
886
+ const lower = raw.toLowerCase();
887
+ if (!lower)
888
+ return 'Gemini';
889
+ if (lower.includes('flash-lite'))
890
+ return 'Flash Lite';
891
+ if (lower.includes('flash'))
892
+ return 'Flash';
893
+ if (lower.includes('pro'))
894
+ return 'Pro';
895
+ return raw
896
+ .replace(/^gemini-/i, '')
897
+ .replace(/[-_]+/g, ' ')
898
+ .trim() || 'Gemini';
899
+ }
900
+ function geminiUsageStatus(usedPercent) {
901
+ if (usedPercent == null)
902
+ return null;
903
+ if (usedPercent >= 100)
904
+ return 'limit_reached';
905
+ if (usedPercent >= 80)
906
+ return 'warning';
907
+ return 'allowed';
908
+ }
909
+ function geminiResetAt(value) {
910
+ const raw = typeof value === 'string' ? value.trim() : '';
911
+ if (!raw)
912
+ return null;
913
+ const ms = Date.parse(raw);
914
+ return Number.isFinite(ms) ? new Date(ms).toISOString() : null;
915
+ }
916
+ function geminiResetAtMs(value) {
917
+ if (!value)
918
+ return Number.POSITIVE_INFINITY;
919
+ const ms = Date.parse(value);
920
+ return Number.isFinite(ms) ? ms : Number.POSITIVE_INFINITY;
921
+ }
922
+ function geminiUsageWindowSort(label) {
923
+ switch (label) {
924
+ case 'Pro': return 0;
925
+ case 'Flash': return 1;
926
+ case 'Flash Lite': return 2;
927
+ default: return 10;
928
+ }
929
+ }
930
+ function parseGeminiUsageResponse(data, capturedAt) {
931
+ const buckets = Array.isArray(data?.buckets) ? data.buckets : [];
932
+ const grouped = new Map();
933
+ for (const bucket of buckets) {
934
+ const remainingFraction = Number(bucket?.remainingFraction);
935
+ if (!Number.isFinite(remainingFraction))
936
+ continue;
937
+ const label = geminiUsageLabel(bucket?.modelId);
938
+ const resetAt = geminiResetAt(bucket?.resetTime);
939
+ const prev = grouped.get(label);
940
+ if (!prev
941
+ || remainingFraction < prev.remainingFraction
942
+ || (remainingFraction === prev.remainingFraction && geminiResetAtMs(resetAt) < geminiResetAtMs(prev.resetAt))) {
943
+ grouped.set(label, { label, remainingFraction, resetAt });
944
+ }
945
+ }
946
+ const windows = [...grouped.values()]
947
+ .map(entry => {
948
+ const usedPercent = roundPercent((1 - entry.remainingFraction) * 100);
949
+ const remainingPercent = roundPercent(entry.remainingFraction * 100);
950
+ let resetAfterSeconds = null;
951
+ if (entry.resetAt) {
952
+ const resetAtMs = Date.parse(entry.resetAt);
953
+ if (Number.isFinite(resetAtMs))
954
+ resetAfterSeconds = Math.max(0, Math.round((resetAtMs - Date.now()) / 1000));
955
+ }
956
+ return {
957
+ label: entry.label,
958
+ usedPercent,
959
+ remainingPercent,
960
+ resetAt: entry.resetAt,
961
+ resetAfterSeconds,
962
+ status: geminiUsageStatus(usedPercent),
963
+ };
964
+ })
965
+ .sort((a, b) => {
966
+ const byLabel = geminiUsageWindowSort(a.label) - geminiUsageWindowSort(b.label);
967
+ return byLabel || a.label.localeCompare(b.label);
968
+ });
969
+ if (!windows.length)
970
+ return null;
971
+ const status = windows.some(window => window.status === 'limit_reached') ? 'limit_reached'
972
+ : windows.some(window => window.status === 'warning') ? 'warning'
973
+ : 'allowed';
974
+ return { ok: true, agent: 'gemini', source: 'quota-api', capturedAt, status, windows, error: null };
975
+ }
976
+ function geminiUsageError(status, bodyText) {
977
+ let detail = '';
978
+ const trimmed = String(bodyText || '').trim();
979
+ if (trimmed && trimmed[0] === '{') {
980
+ try {
981
+ const parsed = JSON.parse(trimmed);
982
+ detail = normalizeErrorMessage(parsed?.error?.message)
983
+ || normalizeErrorMessage(parsed?.error)
984
+ || normalizeErrorMessage(parsed?.message)
985
+ || '';
986
+ }
987
+ catch { }
988
+ }
989
+ return cachedGeminiUsage(`HTTP ${status}${detail ? `: ${detail}` : ''}`);
990
+ }
991
+ async function getGeminiUsageLive() {
992
+ const token = getGeminiOAuthToken();
993
+ if (!token)
994
+ return cachedGeminiUsage('Gemini OAuth token not found.');
995
+ try {
996
+ const raw = execSync(`curl -sS --max-time ${Math.ceil(GEMINI_USAGE_TIMEOUT_MS / 1000)} -w '\\n%{http_code}' -H ${Q(`Authorization: Bearer ${token}`)} -H 'Content-Type: application/json' -d '{}' ${Q(GEMINI_USAGE_URL)}`, { encoding: 'utf-8', timeout: GEMINI_USAGE_TIMEOUT_MS + GEMINI_USAGE_TIMEOUTS.execSyncBuffer });
997
+ const trimmed = raw.trimEnd();
998
+ const sep = trimmed.lastIndexOf('\n');
999
+ const bodyText = sep >= 0 ? trimmed.slice(0, sep) : '';
1000
+ const status = Number(sep >= 0 ? trimmed.slice(sep + 1).trim() : '');
1001
+ if (!Number.isFinite(status))
1002
+ return cachedGeminiUsage('Gemini quota query returned an invalid HTTP status.');
1003
+ if (status < 200 || status >= 300)
1004
+ return geminiUsageError(status, bodyText);
1005
+ if (!bodyText.trim() || bodyText.trim()[0] !== '{')
1006
+ return cachedGeminiUsage('Gemini quota query returned an invalid response.');
1007
+ const usage = parseGeminiUsageResponse(JSON.parse(bodyText), new Date().toISOString())
1008
+ || cachedGeminiUsage('No Gemini quota buckets returned.');
1009
+ if (usage.ok)
1010
+ lastGeminiUsage = usage;
1011
+ return usage;
1012
+ }
1013
+ catch (err) {
1014
+ const detail = normalizeErrorMessage(err?.message || err) || 'Gemini usage query failed.';
1015
+ return cachedGeminiUsage(detail);
1016
+ }
1017
+ }
1018
+ // ---------------------------------------------------------------------------
1019
+ // Driver
1020
+ // ---------------------------------------------------------------------------
1021
+ class GeminiDriver {
1022
+ id = 'gemini';
1023
+ cmd = 'gemini';
1024
+ thinkLabel = 'Thinking';
1025
+ acceptedProviderKinds = ['google'];
1026
+ async doStream(opts) { return doGeminiStream(opts); }
1027
+ async getSessions(workdir, limit) {
1028
+ return getGeminiSessions(workdir, limit);
1029
+ }
1030
+ async getSessionTail(opts) {
1031
+ return getGeminiSessionTail(opts);
1032
+ }
1033
+ async getSessionMessages(opts) {
1034
+ return getGeminiSessionMessages(opts);
1035
+ }
1036
+ async listModels(_opts) {
1037
+ return { agent: 'gemini', models: [...GEMINI_MODELS], sources: [], note: null };
1038
+ }
1039
+ getUsage(_opts) {
1040
+ return cachedGeminiUsage('No recent Gemini usage data found.');
1041
+ }
1042
+ async getUsageLive(_opts) {
1043
+ return getGeminiUsageLive();
1044
+ }
1045
+ async deleteNativeSession(workdir, sessionId) {
1046
+ const file = findGeminiSessionFile(workdir, sessionId);
1047
+ if (!file)
1048
+ return [];
1049
+ try {
1050
+ fs.rmSync(file, { force: true });
1051
+ return [file];
1052
+ }
1053
+ catch {
1054
+ return [];
1055
+ }
1056
+ }
1057
+ shutdown() { }
1058
+ }
1059
+ registerDriver(new GeminiDriver());