dr-claw 1.1.2
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/LICENSE +1380 -0
- package/NOTICE +17 -0
- package/README.md +875 -0
- package/README.zh-CN.md +857 -0
- package/dist/api-docs.html +878 -0
- package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/dist/assets/_basePickBy-C0n1uo4u.js +1 -0
- package/dist/assets/_baseUniq-BRNmhFkc.js +1 -0
- package/dist/assets/arc-DOMBmC_A.js +1 -0
- package/dist/assets/architectureDiagram-VXUJARFQ-CtMWglbD.js +36 -0
- package/dist/assets/blockDiagram-VD42YOAC-DK_gTkJy.js +122 -0
- package/dist/assets/c4Diagram-YG6GDRKO-DrDAvsn7.js +10 -0
- package/dist/assets/channel-BFOE7OyC.js +1 -0
- package/dist/assets/chunk-4BX2VUAB-CGbhdZcN.js +1 -0
- package/dist/assets/chunk-55IACEB6-sGeFSxen.js +1 -0
- package/dist/assets/chunk-B4BG7PRW-RxgHAU_P.js +165 -0
- package/dist/assets/chunk-DI55MBZ5-CvHUXZrx.js +220 -0
- package/dist/assets/chunk-FMBD7UC4-Sn7cXVNU.js +15 -0
- package/dist/assets/chunk-QN33PNHL-bDKOHppN.js +1 -0
- package/dist/assets/chunk-QZHKN3VN-B9kN5LGm.js +1 -0
- package/dist/assets/chunk-TZMSLE5B-CCBe6len.js +1 -0
- package/dist/assets/classDiagram-2ON5EDUG-DQoMNYlY.js +1 -0
- package/dist/assets/classDiagram-v2-WZHVMYZB-DQoMNYlY.js +1 -0
- package/dist/assets/clone-BulQSblH.js +1 -0
- package/dist/assets/cose-bilkent-S5V4N54A-r6sLiHBW.js +1 -0
- package/dist/assets/cytoscape.esm-5J0xJHOV.js +321 -0
- package/dist/assets/dagre-6UL2VRFP-AbsaR1Oa.js +4 -0
- package/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/dist/assets/diagram-PSM6KHXK-DjCvdUyy.js +24 -0
- package/dist/assets/diagram-QEK2KX5R-CqiocXkH.js +43 -0
- package/dist/assets/diagram-S2PKOQOG-D5ALeoII.js +24 -0
- package/dist/assets/erDiagram-Q2GNP2WA-C1AeOsiz.js +60 -0
- package/dist/assets/flowDiagram-NV44I4VS-C98ve_DO.js +162 -0
- package/dist/assets/ganttDiagram-JELNMOA3-3zdLvcxD.js +267 -0
- package/dist/assets/gitGraphDiagram-V2S2FVAM-nDuDMsHh.js +65 -0
- package/dist/assets/graph-Blm-emkR.js +1 -0
- package/dist/assets/index-B8Yutsnm.css +1 -0
- package/dist/assets/index-Cf6gyhc3.js +654 -0
- package/dist/assets/infoDiagram-HS3SLOUP-CmzE3q0R.js +2 -0
- package/dist/assets/init-Gi6I4Gst.js +1 -0
- package/dist/assets/journeyDiagram-XKPGCS4Q-CYaW7LrP.js +139 -0
- package/dist/assets/kanban-definition-3W4ZIXB7-mkmW7p0j.js +89 -0
- package/dist/assets/layout-Ct2kFIId.js +1 -0
- package/dist/assets/linear-CKG9EwIJ.js +1 -0
- package/dist/assets/mermaid.core-CaKcUnnd.js +250 -0
- package/dist/assets/mindmap-definition-VGOIOE7T-Bct-pYin.js +68 -0
- package/dist/assets/ordinal-Cboi1Yqb.js +1 -0
- package/dist/assets/pieDiagram-ADFJNKIX-C8wFD4Jr.js +30 -0
- package/dist/assets/quadrantDiagram-AYHSOK5B-CuiE_f46.js +7 -0
- package/dist/assets/requirementDiagram-UZGBJVZJ-C4v1CqU8.js +64 -0
- package/dist/assets/sankeyDiagram-TZEHDZUN-keAqqs-Y.js +10 -0
- package/dist/assets/sequenceDiagram-WL72ISMW-ClNbvKTB.js +145 -0
- package/dist/assets/stateDiagram-FKZM4ZOC-D8We-2vq.js +1 -0
- package/dist/assets/stateDiagram-v2-4FDKWEC3-BUURfjrE.js +1 -0
- package/dist/assets/timeline-definition-IT6M3QCI-DsvEMT5T.js +61 -0
- package/dist/assets/treemap-GDKQZRPO-TJrzN7Bj.js +162 -0
- package/dist/assets/vendor-codemirror-ansM-eig.js +33 -0
- package/dist/assets/vendor-react-BWF6YQQ3.js +8 -0
- package/dist/assets/vendor-xterm-DfcmCpbH.js +66 -0
- package/dist/assets/xychartDiagram-PRI3JC2R-BdATmY5F.js +7 -0
- package/dist/clear-cache.html +85 -0
- package/dist/convert-icons.md +53 -0
- package/dist/dr-claw.png +0 -0
- package/dist/favicon.png +0 -0
- package/dist/generate-icons.js +49 -0
- package/dist/icons/claude-ai-icon.svg +1 -0
- package/dist/icons/codex-white.svg +3 -0
- package/dist/icons/codex.svg +3 -0
- package/dist/icons/cursor-white.svg +12 -0
- package/dist/icons/cursor.svg +1 -0
- package/dist/icons/file.svg +16997 -0
- package/dist/icons/generate-icons.md +19 -0
- package/dist/icons/icon-128x128.png +0 -0
- package/dist/icons/icon-144x144.png +0 -0
- package/dist/icons/icon-152x152.png +0 -0
- package/dist/icons/icon-192x192.png +0 -0
- package/dist/icons/icon-384x384.png +0 -0
- package/dist/icons/icon-512x512.png +0 -0
- package/dist/icons/icon-72x72.png +0 -0
- package/dist/icons/icon-96x96.png +0 -0
- package/dist/icons/news/arxiv.svg +1 -0
- package/dist/icons/news/huggingface.svg +8 -0
- package/dist/icons/news/x-black.png +0 -0
- package/dist/icons/news/x-white.png +0 -0
- package/dist/icons/news/xiaohongshu.png +0 -0
- package/dist/index.html +51 -0
- package/dist/leveraged-cognition.png +0 -0
- package/dist/manifest.json +61 -0
- package/dist/screenshots/chat.png +0 -0
- package/dist/screenshots/new_claw.png +0 -0
- package/dist/screenshots/news_dashboard.png +0 -0
- package/dist/screenshots/project_dashboard.png +0 -0
- package/dist/screenshots/research_lab.png +0 -0
- package/dist/screenshots/skill_library.png +0 -0
- package/dist/screenshots/task_list.png +0 -0
- package/dist/screenshots/terminal_example.png +0 -0
- package/dist/screenshots/terminal_example1.png +0 -0
- package/dist/sw.js +58 -0
- package/dist/wechat-group-qr.jpg +0 -0
- package/package.json +133 -0
- package/public/api-docs.html +878 -0
- package/public/clear-cache.html +85 -0
- package/public/convert-icons.md +53 -0
- package/public/dr-claw.png +0 -0
- package/public/favicon.png +0 -0
- package/public/generate-icons.js +49 -0
- package/public/icons/claude-ai-icon.svg +1 -0
- package/public/icons/codex-white.svg +3 -0
- package/public/icons/codex.svg +3 -0
- package/public/icons/cursor-white.svg +12 -0
- package/public/icons/cursor.svg +1 -0
- package/public/icons/file.svg +16997 -0
- package/public/icons/generate-icons.md +19 -0
- package/public/icons/icon-128x128.png +0 -0
- package/public/icons/icon-144x144.png +0 -0
- package/public/icons/icon-152x152.png +0 -0
- package/public/icons/icon-192x192.png +0 -0
- package/public/icons/icon-384x384.png +0 -0
- package/public/icons/icon-512x512.png +0 -0
- package/public/icons/icon-72x72.png +0 -0
- package/public/icons/icon-96x96.png +0 -0
- package/public/icons/news/arxiv.svg +1 -0
- package/public/icons/news/huggingface.svg +8 -0
- package/public/icons/news/x-black.png +0 -0
- package/public/icons/news/x-white.png +0 -0
- package/public/icons/news/xiaohongshu.png +0 -0
- package/public/leveraged-cognition.png +0 -0
- package/public/manifest.json +61 -0
- package/public/screenshots/chat.png +0 -0
- package/public/screenshots/new_claw.png +0 -0
- package/public/screenshots/news_dashboard.png +0 -0
- package/public/screenshots/project_dashboard.png +0 -0
- package/public/screenshots/research_lab.png +0 -0
- package/public/screenshots/skill_library.png +0 -0
- package/public/screenshots/task_list.png +0 -0
- package/public/screenshots/terminal_example.png +0 -0
- package/public/screenshots/terminal_example1.png +0 -0
- package/public/sw.js +58 -0
- package/public/wechat-group-qr.jpg +0 -0
- package/scripts/export-skills-catalog-v2.mjs +746 -0
- package/scripts/fix-node-pty.js +45 -0
- package/scripts/gemini_image_core.py +470 -0
- package/scripts/install-python-tools.js +64 -0
- package/server/AGENTS.md +73 -0
- package/server/__tests__/gemini-legacy-thought.test.mjs +88 -0
- package/server/__tests__/runtimePorts.test.mjs +115 -0
- package/server/__tests__/session-delete.test.mjs +166 -0
- package/server/bin/compute-node +70 -0
- package/server/claude-sdk.js +799 -0
- package/server/cli-chat.js +488 -0
- package/server/cli.js +354 -0
- package/server/compute-node.js +439 -0
- package/server/constants/config.js +5 -0
- package/server/cursor-cli.js +340 -0
- package/server/database/auth.db +0 -0
- package/server/database/db.js +1852 -0
- package/server/database/init.sql +194 -0
- package/server/gemini-cli.js +1352 -0
- package/server/index.js +3154 -0
- package/server/load-env.js +71 -0
- package/server/local-gpu.js +860 -0
- package/server/middleware/auth.js +100 -0
- package/server/openai-codex.js +724 -0
- package/server/openrouter.js +890 -0
- package/server/project-token-usage.js +407 -0
- package/server/projects.js +3973 -0
- package/server/routes/agent.js +1272 -0
- package/server/routes/auth.js +128 -0
- package/server/routes/auto-research.js +558 -0
- package/server/routes/cli-auth.js +958 -0
- package/server/routes/codex.js +344 -0
- package/server/routes/commands.js +601 -0
- package/server/routes/compute.js +358 -0
- package/server/routes/cursor.js +795 -0
- package/server/routes/git.js +1348 -0
- package/server/routes/mcp-utils.js +48 -0
- package/server/routes/mcp.js +618 -0
- package/server/routes/news.js +711 -0
- package/server/routes/projects.js +633 -0
- package/server/routes/references.js +338 -0
- package/server/routes/settings.js +285 -0
- package/server/routes/skills.js +914 -0
- package/server/routes/taskmaster.js +2566 -0
- package/server/routes/telemetry.js +89 -0
- package/server/routes/user.js +155 -0
- package/server/scripts/fix_session_names.js +57 -0
- package/server/scripts/research-news/common_words.py +19 -0
- package/server/scripts/research-news/link_keywords.py +298 -0
- package/server/scripts/research-news/scan_existing_notes.py +266 -0
- package/server/scripts/research-news/scoring_utils.py +227 -0
- package/server/scripts/research-news/search_arxiv.py +842 -0
- package/server/scripts/research-news/search_huggingface.py +438 -0
- package/server/scripts/research-news/search_x.py +490 -0
- package/server/scripts/research-news/search_xiaohongshu.py +610 -0
- package/server/taskmaster-templates/README.md +150 -0
- package/server/taskmaster-templates/README.zh-CN.md +150 -0
- package/server/taskmaster-templates/ai-research-dataset.json +230 -0
- package/server/taskmaster-templates/ai-research-method-model.json +230 -0
- package/server/taskmaster-templates/ai-research-position-paper.json +230 -0
- package/server/telemetry.js +121 -0
- package/server/templates/AGENTS.md +103 -0
- package/server/templates/CLAUDE.md +104 -0
- package/server/templates/GEMINI.md +103 -0
- package/server/templates/cursor-project.md +108 -0
- package/server/templates/index.js +37 -0
- package/server/utils/__tests__/sessionTokenUsage.test.js +82 -0
- package/server/utils/auto-research-mailer.js +57 -0
- package/server/utils/cliResolution.js +170 -0
- package/server/utils/commandParser.js +303 -0
- package/server/utils/cursorCommand.js +61 -0
- package/server/utils/geminiApiKey.js +28 -0
- package/server/utils/gitConfig.js +24 -0
- package/server/utils/imageAttachmentFiles.js +35 -0
- package/server/utils/mcp-detector.js +198 -0
- package/server/utils/parsers/bibtex-parser.js +207 -0
- package/server/utils/permissions.js +100 -0
- package/server/utils/runtimePorts.js +212 -0
- package/server/utils/sessionFormatting.js +68 -0
- package/server/utils/sessionIndex.js +100 -0
- package/server/utils/sessionMode.js +95 -0
- package/server/utils/sessionTokenUsage.js +105 -0
- package/server/utils/taskmaster-websocket.js +129 -0
- package/server/utils/zotero-client.js +136 -0
- package/shared/errorClassifier.js +102 -0
- package/shared/geminiThinkingSupport.d.ts +22 -0
- package/shared/geminiThinkingSupport.js +96 -0
- package/shared/geminiThoughtParser.d.ts +8 -0
- package/shared/geminiThoughtParser.js +30 -0
- package/shared/modelConstants.js +139 -0
|
@@ -0,0 +1,1352 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import crossSpawn from 'cross-spawn';
|
|
3
|
+
import { promises as fs } from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import os from 'os';
|
|
6
|
+
import { createRequestId, waitForToolApproval, matchesToolPermission } from './utils/permissions.js';
|
|
7
|
+
import { encodeProjectPath, ensureProjectSkillLinks, reconcileGeminiSessionIndex } from './projects.js';
|
|
8
|
+
import { writeProjectTemplates } from './templates/index.js';
|
|
9
|
+
import { stripInternalContextPrefix } from './utils/sessionFormatting.js';
|
|
10
|
+
import { applyStageTagsToSession, recordIndexedSession } from './utils/sessionIndex.js';
|
|
11
|
+
import { buildTempAttachmentFilename, toPortableAtPath } from './utils/imageAttachmentFiles.js';
|
|
12
|
+
import { splitLegacyGeminiThoughtContent } from '../shared/geminiThoughtParser.js';
|
|
13
|
+
import { classifyError } from '../shared/errorClassifier.js';
|
|
14
|
+
import { buildGeminiThinkingConfig } from '../shared/geminiThinkingSupport.js';
|
|
15
|
+
|
|
16
|
+
// Use cross-spawn on Windows for better command execution
|
|
17
|
+
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
|
18
|
+
|
|
19
|
+
let activeGeminiSessions = new Map(); // Track active sessions: { process, heartbeat, sessionId, options, sessionAllowedTools, sessionDisallowedTools }
|
|
20
|
+
const GEMINI_DEFAULT_CONTEXT_WINDOW = parseInt(process.env.GEMINI_CONTEXT_WINDOW || process.env.CONTEXT_WINDOW || '2000000', 10);
|
|
21
|
+
|
|
22
|
+
const GEMINI_TOOL_NAME_MAP = {
|
|
23
|
+
// Official Gemini CLI tool names
|
|
24
|
+
run_shell_command: 'Bash',
|
|
25
|
+
glob: 'Glob',
|
|
26
|
+
grep_search: 'Grep',
|
|
27
|
+
list_directory: 'LS',
|
|
28
|
+
read_file: 'Read',
|
|
29
|
+
read_many_files: 'Read',
|
|
30
|
+
replace: 'Edit',
|
|
31
|
+
write_file: 'Write',
|
|
32
|
+
ask_user: 'AskUserQuestion',
|
|
33
|
+
write_todos: 'TodoWrite',
|
|
34
|
+
enter_plan_mode: 'enter_plan_mode',
|
|
35
|
+
exit_plan_mode: 'exit_plan_mode',
|
|
36
|
+
google_web_search: 'WebSearch',
|
|
37
|
+
web_fetch: 'WebFetch',
|
|
38
|
+
|
|
39
|
+
// Backward-compatible aliases seen in older wrappers
|
|
40
|
+
insert_content: 'Edit',
|
|
41
|
+
todo_read: 'TodoRead',
|
|
42
|
+
todo_write: 'TodoWrite',
|
|
43
|
+
task_get: 'TaskGet',
|
|
44
|
+
task_list: 'TaskList',
|
|
45
|
+
task_create: 'TaskCreate',
|
|
46
|
+
task_update: 'TaskUpdate',
|
|
47
|
+
ask_user_question: 'AskUserQuestion'
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const GEMINI_PLAN_MODE_TOOLS = [
|
|
51
|
+
'read_file',
|
|
52
|
+
'read_many_files',
|
|
53
|
+
'list_directory',
|
|
54
|
+
'glob',
|
|
55
|
+
'grep_search',
|
|
56
|
+
'write_todos',
|
|
57
|
+
'ask_user',
|
|
58
|
+
'enter_plan_mode',
|
|
59
|
+
'exit_plan_mode',
|
|
60
|
+
'google_web_search',
|
|
61
|
+
'web_fetch',
|
|
62
|
+
'activate_skill',
|
|
63
|
+
'save_memory',
|
|
64
|
+
'get_internal_docs'
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
const GEMINI_INTERACTIVE_TOOLS = new Set(['ask_user', 'ask_user_question', 'AskUserQuestion']);
|
|
68
|
+
const GEMINI_PLAN_BLOCKED_TOOLS = new Set([
|
|
69
|
+
'run_shell_command',
|
|
70
|
+
'write_file',
|
|
71
|
+
'replace',
|
|
72
|
+
'insert_content',
|
|
73
|
+
'undo',
|
|
74
|
+
'complete_task',
|
|
75
|
+
'Bash',
|
|
76
|
+
'Write',
|
|
77
|
+
'Edit'
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
async function persistGeminiSessionMetadata(sessionId, projectPath, sessionMode) {
|
|
81
|
+
if (!sessionId || !projectPath) return;
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const { sessionDb } = await import('./database/db.js');
|
|
85
|
+
sessionDb.upsertSession(
|
|
86
|
+
sessionId,
|
|
87
|
+
encodeProjectPath(projectPath),
|
|
88
|
+
'gemini',
|
|
89
|
+
'Untitled Session',
|
|
90
|
+
new Date().toISOString(),
|
|
91
|
+
0,
|
|
92
|
+
{ sessionMode: sessionMode || 'research' },
|
|
93
|
+
);
|
|
94
|
+
} catch (error) {
|
|
95
|
+
console.warn('[Gemini] Failed to persist session metadata:', error.message);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function normalizeGeminiToolName(name) {
|
|
100
|
+
if (!name || typeof name !== 'string') return name;
|
|
101
|
+
const normalized = name.trim();
|
|
102
|
+
return GEMINI_TOOL_NAME_MAP[normalized] || normalized;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function ensurePlanModeAllowedTools(allowedTools = []) {
|
|
106
|
+
const merged = new Set(allowedTools);
|
|
107
|
+
for (const tool of GEMINI_PLAN_MODE_TOOLS) {
|
|
108
|
+
merged.add(tool);
|
|
109
|
+
const alias = GEMINI_TOOL_NAME_MAP[tool];
|
|
110
|
+
if (alias) merged.add(alias);
|
|
111
|
+
}
|
|
112
|
+
return Array.from(merged);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function isLikelyMultiStepRequest(command) {
|
|
116
|
+
const text = String(command || '').trim();
|
|
117
|
+
if (!text) return false;
|
|
118
|
+
|
|
119
|
+
const numberedSteps = (text.match(/^\s*\d+\.\s+/gm) || []).length;
|
|
120
|
+
const bulletSteps = (text.match(/^\s*[-*]\s+/gm) || []).length;
|
|
121
|
+
const explicitMultiStep = /\b(multi[- ]step|step by step|several tasks|task list|pipeline)\b/i.test(text);
|
|
122
|
+
const transitionWords = (text.match(/\b(first|then|next|after that|finally)\b/gi) || []).length;
|
|
123
|
+
|
|
124
|
+
return numberedSteps >= 2 || bulletSteps >= 2 || explicitMultiStep || transitionWords >= 2;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function isAllowedBeforeTodos(rawToolName) {
|
|
128
|
+
const normalized = String(rawToolName || '').trim().toLowerCase();
|
|
129
|
+
return [
|
|
130
|
+
'write_todos',
|
|
131
|
+
'todo_write',
|
|
132
|
+
'activate_skill',
|
|
133
|
+
'enter_plan_mode'
|
|
134
|
+
].includes(normalized);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function sanitizePersistedGeminiContent(content) {
|
|
138
|
+
if (typeof content === 'string') {
|
|
139
|
+
return stripInternalContextPrefix(content, false);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (Array.isArray(content)) {
|
|
143
|
+
return content.map((part) => {
|
|
144
|
+
if (part && typeof part === 'object' && typeof part.text === 'string') {
|
|
145
|
+
return { ...part, text: stripInternalContextPrefix(part.text, false) };
|
|
146
|
+
}
|
|
147
|
+
return part;
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return content;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Exported for testing only
|
|
155
|
+
export function normalizePersistedGeminiAssistantEntries(content) {
|
|
156
|
+
const normalizedContent = typeof content === 'string'
|
|
157
|
+
? stripInternalContextPrefix(content, false)
|
|
158
|
+
: content;
|
|
159
|
+
const legacySegments = splitLegacyGeminiThoughtContent(normalizedContent);
|
|
160
|
+
|
|
161
|
+
if (!legacySegments) {
|
|
162
|
+
return [{
|
|
163
|
+
role: 'assistant',
|
|
164
|
+
content: normalizedContent,
|
|
165
|
+
type: 'message'
|
|
166
|
+
}];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return legacySegments.map((segment) => segment.isThinking
|
|
170
|
+
? { role: 'assistant', type: 'thinking', content: segment.content }
|
|
171
|
+
: { role: 'assistant', content: segment.content, type: 'message' }
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function summarizeGeminiErrorOutput(rawErrorOutput, fallbackModel) {
|
|
176
|
+
const text = String(rawErrorOutput || '').trim();
|
|
177
|
+
if (!text) {
|
|
178
|
+
return { summary: 'Gemini request failed.', details: '' };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const isCapacity429 =
|
|
182
|
+
/status\s+429|Too Many Requests|RESOURCE_EXHAUSTED|MODEL_CAPACITY_EXHAUSTED|No capacity available/i.test(text);
|
|
183
|
+
if (isCapacity429) {
|
|
184
|
+
const modelMatch =
|
|
185
|
+
text.match(/No capacity available for model ([a-zA-Z0-9._-]+)/i) ||
|
|
186
|
+
text.match(/"model"\s*:\s*"([^"]+)"/i) ||
|
|
187
|
+
text.match(/GeminiCLI\/[^\s/]+\/([a-zA-Z0-9._-]+)/i);
|
|
188
|
+
const modelName = modelMatch?.[1] || fallbackModel || 'current model';
|
|
189
|
+
return {
|
|
190
|
+
summary: `Model capacity exhausted for ${modelName} (HTTP 429). Please retry in a moment or switch to a Flash/Lite model.`,
|
|
191
|
+
details: text.slice(0, 12000)
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const concise = text.split('\n').map((line) => line.trim()).find(Boolean) || 'Gemini request failed.';
|
|
196
|
+
return {
|
|
197
|
+
summary: concise.slice(0, 400),
|
|
198
|
+
details: text.slice(0, 12000)
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function inferProjectName(workingDir, projectPath) {
|
|
203
|
+
const candidate = projectPath || workingDir;
|
|
204
|
+
if (!candidate || typeof candidate !== 'string') return null;
|
|
205
|
+
return path.basename(candidate.replace(/[\\/]+$/, '')) || null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function shouldNotifyTaskMasterRefresh(toolName, toolInput, toolResultOutput) {
|
|
209
|
+
const normalizedName = String(toolName || '').toLowerCase();
|
|
210
|
+
if (
|
|
211
|
+
normalizedName.startsWith('task_') ||
|
|
212
|
+
normalizedName === 'todo_write' ||
|
|
213
|
+
normalizedName === 'todo_read' ||
|
|
214
|
+
normalizedName === 'write_todos'
|
|
215
|
+
) {
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const serializedInput = JSON.stringify(toolInput || {});
|
|
220
|
+
if (normalizedName === 'write_file' || normalizedName === 'replace' || normalizedName === 'insert_content') {
|
|
221
|
+
if (serializedInput.includes('.pipeline/tasks/tasks.json')) return true;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (normalizedName === 'run_shell_command') {
|
|
225
|
+
const commandText = String(toolInput?.command || toolInput?.cmd || '');
|
|
226
|
+
if (/taskmaster|task-master|\.pipeline\/tasks\/tasks\.json/i.test(commandText)) return true;
|
|
227
|
+
const outputText = typeof toolResultOutput === 'string' ? toolResultOutput : '';
|
|
228
|
+
if (/taskmaster|task-master|tasks\.json/i.test(outputText)) return true;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function shouldNotifyProjectRefresh(toolName, toolInput, toolResultOutput) {
|
|
235
|
+
const normalizedName = String(toolName || '').toLowerCase();
|
|
236
|
+
const serializedInput = JSON.stringify(toolInput || {});
|
|
237
|
+
const outputText = typeof toolResultOutput === 'string' ? toolResultOutput : '';
|
|
238
|
+
|
|
239
|
+
if (normalizedName === 'write_file' || normalizedName === 'replace' || normalizedName === 'insert_content') {
|
|
240
|
+
if (
|
|
241
|
+
serializedInput.includes('.pipeline/docs/research_brief.json') ||
|
|
242
|
+
serializedInput.includes('instance.json') ||
|
|
243
|
+
serializedInput.includes('.pipeline/config.json')
|
|
244
|
+
) {
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (normalizedName === 'run_shell_command') {
|
|
250
|
+
const commandText = String(toolInput?.command || toolInput?.cmd || '');
|
|
251
|
+
if (/research_brief\.json|instance\.json|\.pipeline\/config\.json/i.test(commandText)) return true;
|
|
252
|
+
if (/research_brief\.json|instance\.json|\.pipeline\/config\.json/i.test(outputText)) return true;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function resolveCanonicalProjectFilePath(filePath) {
|
|
259
|
+
if (!filePath || typeof filePath !== 'string') return filePath;
|
|
260
|
+
const trimmed = filePath.trim();
|
|
261
|
+
if (!trimmed) return trimmed;
|
|
262
|
+
|
|
263
|
+
// Keep explicit absolute or already-nested paths unchanged.
|
|
264
|
+
if (path.isAbsolute(trimmed) || trimmed.includes('/')) return trimmed;
|
|
265
|
+
|
|
266
|
+
const canonicalCandidates = {
|
|
267
|
+
'research_brief.json': ['.pipeline/docs/research_brief.json'],
|
|
268
|
+
'tasks.json': ['.pipeline/tasks/tasks.json'],
|
|
269
|
+
'pipeline_config.json': ['.pipeline/config.json']
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const candidates = canonicalCandidates[trimmed];
|
|
273
|
+
if (!candidates || candidates.length === 0) return trimmed;
|
|
274
|
+
|
|
275
|
+
// Prefer canonical pipeline location even when the file is not present yet.
|
|
276
|
+
// This prevents accidental writes to guessed root-level filenames.
|
|
277
|
+
return candidates[0];
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function normalizeGeminiToolInput(rawToolName, toolInput) {
|
|
281
|
+
if (!toolInput || typeof toolInput !== 'object') return toolInput;
|
|
282
|
+
const normalizedToolName = String(rawToolName || '').toLowerCase();
|
|
283
|
+
const next = { ...toolInput };
|
|
284
|
+
|
|
285
|
+
if (next.file_path && typeof next.file_path === 'string') {
|
|
286
|
+
if (
|
|
287
|
+
normalizedToolName === 'read_file' ||
|
|
288
|
+
normalizedToolName === 'write_file' ||
|
|
289
|
+
normalizedToolName === 'replace'
|
|
290
|
+
) {
|
|
291
|
+
next.file_path = resolveCanonicalProjectFilePath(next.file_path);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return next;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function parseTodosFromMarkdown(markdown) {
|
|
299
|
+
if (!markdown || typeof markdown !== 'string') return [];
|
|
300
|
+
const lines = markdown.split('\n');
|
|
301
|
+
const todos = [];
|
|
302
|
+
for (const line of lines) {
|
|
303
|
+
const m = line.match(/^\s*-\s*\[( |x|X|~|>|-)\]\s+(.+?)\s*$/);
|
|
304
|
+
if (!m) continue;
|
|
305
|
+
const marker = m[1];
|
|
306
|
+
const description = m[2].replace(/<!--.*?-->/g, '').trim();
|
|
307
|
+
if (!description) continue;
|
|
308
|
+
let status = 'pending';
|
|
309
|
+
if (marker.toLowerCase() === 'x') status = 'completed';
|
|
310
|
+
else if (marker === '>' || marker === '~') status = 'in_progress';
|
|
311
|
+
else if (marker === '-') status = 'cancelled';
|
|
312
|
+
todos.push({ description, status });
|
|
313
|
+
}
|
|
314
|
+
return todos;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function extractTodosFromShellCommand(command) {
|
|
318
|
+
if (!command || typeof command !== 'string') return [];
|
|
319
|
+
if (!/todos\.md/.test(command)) return [];
|
|
320
|
+
const heredocMatch = command.match(/cat\s*<<\s*EOF\s*>\s*[^\n]*todos\.md\s*\n([\s\S]*?)\nEOF/m);
|
|
321
|
+
if (!heredocMatch) return [];
|
|
322
|
+
return parseTodosFromMarkdown(heredocMatch[1]);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function enrichListDirectoryResult(toolContext, outputText, workingDir) {
|
|
326
|
+
if (!toolContext) return outputText;
|
|
327
|
+
const rawName = String(toolContext.rawToolName || '').toLowerCase();
|
|
328
|
+
if (rawName !== 'list_directory') return outputText;
|
|
329
|
+
|
|
330
|
+
const dirInput = String(toolContext.toolInput?.dir_path || toolContext.toolInput?.path || '.');
|
|
331
|
+
const resolvedDir = path.isAbsolute(dirInput)
|
|
332
|
+
? path.resolve(dirInput)
|
|
333
|
+
: path.resolve(workingDir, dirInput);
|
|
334
|
+
const normalizedRoot = path.resolve(workingDir) + path.sep;
|
|
335
|
+
if (!resolvedDir.startsWith(normalizedRoot) && resolvedDir !== path.resolve(workingDir)) {
|
|
336
|
+
return outputText;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
try {
|
|
340
|
+
const entries = await fs.readdir(resolvedDir, { withFileTypes: true });
|
|
341
|
+
const sortedEntries = [...entries].sort((a, b) => {
|
|
342
|
+
if (a.isDirectory() !== b.isDirectory()) {
|
|
343
|
+
return a.isDirectory() ? -1 : 1;
|
|
344
|
+
}
|
|
345
|
+
return a.name.localeCompare(b.name);
|
|
346
|
+
});
|
|
347
|
+
const visibleEntries = sortedEntries
|
|
348
|
+
.slice(0, 200)
|
|
349
|
+
.map((entry) => ({
|
|
350
|
+
name: entry.name,
|
|
351
|
+
isDirectory: entry.isDirectory()
|
|
352
|
+
}));
|
|
353
|
+
|
|
354
|
+
const baseRel = path.relative(workingDir, resolvedDir).replace(/\\/g, '/');
|
|
355
|
+
const relDir = baseRel && baseRel !== '' ? baseRel : '.';
|
|
356
|
+
const items = visibleEntries.map((entry) => {
|
|
357
|
+
const relPath = relDir === '.'
|
|
358
|
+
? entry.name
|
|
359
|
+
: `${relDir}/${entry.name}`;
|
|
360
|
+
return {
|
|
361
|
+
name: entry.name,
|
|
362
|
+
path: `${relPath}${entry.isDirectory ? '/' : ''}`,
|
|
363
|
+
isDirectory: entry.isDirectory
|
|
364
|
+
};
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
return JSON.stringify(
|
|
368
|
+
{
|
|
369
|
+
summary: outputText,
|
|
370
|
+
directory: relDir,
|
|
371
|
+
files: items.map((item) => item.path),
|
|
372
|
+
items,
|
|
373
|
+
total: entries.length,
|
|
374
|
+
truncated: entries.length > visibleEntries.length
|
|
375
|
+
},
|
|
376
|
+
null,
|
|
377
|
+
2
|
|
378
|
+
);
|
|
379
|
+
} catch {
|
|
380
|
+
return outputText;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async function handleGeminiAttachments(command, attachments, workingDir) {
|
|
385
|
+
const tempFilePaths = [];
|
|
386
|
+
let tempDir = null;
|
|
387
|
+
if (!Array.isArray(attachments) || attachments.length === 0) {
|
|
388
|
+
return { modifiedCommand: command, tempFilePaths, tempDir };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
try {
|
|
392
|
+
tempDir = path.join(workingDir || process.cwd(), '.tmp', 'attachments', Date.now().toString());
|
|
393
|
+
await fs.mkdir(tempDir, { recursive: true });
|
|
394
|
+
|
|
395
|
+
for (const [index, item] of attachments.entries()) {
|
|
396
|
+
const data = String(item?.data || '');
|
|
397
|
+
const matches = data.match(/^data:([^;]+);base64,(.+)$/);
|
|
398
|
+
if (!matches) continue;
|
|
399
|
+
const [, mimeType, base64Data] = matches;
|
|
400
|
+
const filename = buildTempAttachmentFilename(index, item?.name, mimeType);
|
|
401
|
+
const filepath = path.join(tempDir, filename);
|
|
402
|
+
await fs.writeFile(filepath, Buffer.from(base64Data, 'base64'));
|
|
403
|
+
tempFilePaths.push(filepath);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (tempFilePaths.length === 0) {
|
|
407
|
+
return { modifiedCommand: command, tempFilePaths, tempDir };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const referencedPaths = tempFilePaths.map((filePath) => {
|
|
411
|
+
return toPortableAtPath(filePath, workingDir || process.cwd());
|
|
412
|
+
});
|
|
413
|
+
const note = `\n\nAttached files:\n${referencedPaths.join('\n')}`;
|
|
414
|
+
return { modifiedCommand: `${command}${note}`, tempFilePaths, tempDir };
|
|
415
|
+
} catch (error) {
|
|
416
|
+
console.error('[Gemini] Failed to process attachments:', error.message);
|
|
417
|
+
return { modifiedCommand: command, tempFilePaths, tempDir };
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async function cleanupGeminiTempFiles(tempFilePaths, tempDirs) {
|
|
422
|
+
if (!Array.isArray(tempFilePaths) || tempFilePaths.length === 0) return;
|
|
423
|
+
for (const filePath of tempFilePaths) {
|
|
424
|
+
try { await fs.unlink(filePath); } catch {}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (!Array.isArray(tempDirs)) return;
|
|
428
|
+
for (const tempDir of new Set(tempDirs.filter(Boolean))) {
|
|
429
|
+
try { await fs.rm(tempDir, { recursive: true, force: true }); } catch {}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function isPlainObject(value) {
|
|
434
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function deepMergeObjects(baseValue, overrideValue) {
|
|
438
|
+
if (!isPlainObject(baseValue)) {
|
|
439
|
+
return isPlainObject(overrideValue) ? { ...overrideValue } : overrideValue;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if (!isPlainObject(overrideValue)) {
|
|
443
|
+
return overrideValue;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const merged = { ...baseValue };
|
|
447
|
+
for (const [key, value] of Object.entries(overrideValue)) {
|
|
448
|
+
if (isPlainObject(value) && isPlainObject(merged[key])) {
|
|
449
|
+
merged[key] = deepMergeObjects(merged[key], value);
|
|
450
|
+
} else {
|
|
451
|
+
merged[key] = value;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
return merged;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async function prepareGeminiThinkingSettings(model, thinkingMode, env = {}) {
|
|
458
|
+
const thinkingConfig = buildGeminiThinkingConfig(model, thinkingMode);
|
|
459
|
+
if (!thinkingConfig || !model) {
|
|
460
|
+
return null;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const aliasName = '__dr_claw_session_model';
|
|
464
|
+
const overrideSettings = {
|
|
465
|
+
modelConfigs: {
|
|
466
|
+
customAliases: {
|
|
467
|
+
[aliasName]: {
|
|
468
|
+
extends: model,
|
|
469
|
+
modelConfig: {
|
|
470
|
+
model,
|
|
471
|
+
generateContentConfig: {
|
|
472
|
+
thinkingConfig,
|
|
473
|
+
},
|
|
474
|
+
},
|
|
475
|
+
},
|
|
476
|
+
},
|
|
477
|
+
},
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
let mergedSettings = overrideSettings;
|
|
481
|
+
const inheritedSettingsPath = env.GEMINI_CLI_SYSTEM_SETTINGS_PATH;
|
|
482
|
+
if (inheritedSettingsPath) {
|
|
483
|
+
try {
|
|
484
|
+
const inheritedContent = await fs.readFile(inheritedSettingsPath, 'utf8');
|
|
485
|
+
const inheritedSettings = JSON.parse(inheritedContent);
|
|
486
|
+
mergedSettings = deepMergeObjects(inheritedSettings, overrideSettings);
|
|
487
|
+
} catch (error) {
|
|
488
|
+
console.warn(`[Gemini] Failed to read inherited system settings: ${error.message}`);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'dr-claw-gemini-settings-'));
|
|
493
|
+
const settingsPath = path.join(tempDir, 'settings.json');
|
|
494
|
+
await fs.writeFile(settingsPath, JSON.stringify(mergedSettings, null, 2), 'utf8');
|
|
495
|
+
|
|
496
|
+
return {
|
|
497
|
+
cliModel: aliasName,
|
|
498
|
+
settingsPath,
|
|
499
|
+
tempDir,
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Ensures a session directory exists and creates a basic JSONL metadata file if it doesn't.
|
|
505
|
+
* This helps Dr. Claw discover the session even if the CLI hasn't written to it yet.
|
|
506
|
+
*/
|
|
507
|
+
async function syncSessionMetadata(sessionId, projectPath, sessionMode = 'research') {
|
|
508
|
+
if (!sessionId || !projectPath) return;
|
|
509
|
+
|
|
510
|
+
const geminiSessionsDir = path.join(os.homedir(), '.gemini', 'sessions');
|
|
511
|
+
const sessionFile = path.join(geminiSessionsDir, `${sessionId}.jsonl`);
|
|
512
|
+
|
|
513
|
+
try {
|
|
514
|
+
await fs.mkdir(geminiSessionsDir, { recursive: true });
|
|
515
|
+
|
|
516
|
+
// Check if file already exists
|
|
517
|
+
try {
|
|
518
|
+
await fs.access(sessionFile);
|
|
519
|
+
// Already exists, we don't want to overwrite real history
|
|
520
|
+
return;
|
|
521
|
+
} catch (e) {
|
|
522
|
+
// File doesn't exist, create it with metadata
|
|
523
|
+
const timestamp = new Date().toISOString();
|
|
524
|
+
const initialEntry = {
|
|
525
|
+
type: 'session_meta',
|
|
526
|
+
payload: {
|
|
527
|
+
id: sessionId,
|
|
528
|
+
cwd: projectPath,
|
|
529
|
+
timestamp,
|
|
530
|
+
sessionMode: sessionMode || 'research',
|
|
531
|
+
},
|
|
532
|
+
cwd: projectPath, // Compatibility
|
|
533
|
+
timestamp,
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
await fs.writeFile(sessionFile, JSON.stringify(initialEntry) + '\n', 'utf8');
|
|
537
|
+
console.log(`[Gemini] Synced session metadata to ${sessionFile}`);
|
|
538
|
+
}
|
|
539
|
+
} catch (error) {
|
|
540
|
+
console.error(`[Gemini] Failed to sync session metadata: ${error.message}`);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Executes a Gemini CLI query
|
|
546
|
+
* @param {string} command - User prompt/command
|
|
547
|
+
* @param {Object} options - Query options
|
|
548
|
+
* @param {Object} ws - WebSocket connection
|
|
549
|
+
* @returns {Promise<void>}
|
|
550
|
+
*/
|
|
551
|
+
export async function spawnGemini(command, options = {}, ws) {
|
|
552
|
+
return new Promise(async (resolve, reject) => {
|
|
553
|
+
const { sessionId, projectPath, cwd, model, images, attachments, permissionMode, thinkingMode, toolsSettings, sessionMode, stageTagKeys, stageTagSource = 'task_context', env } = options;
|
|
554
|
+
let capturedSessionId = sessionId;
|
|
555
|
+
let sessionCreatedSent = false;
|
|
556
|
+
let messageStartedSent = false;
|
|
557
|
+
let contentBlockStarted = false;
|
|
558
|
+
let currentBlockIndex = 0;
|
|
559
|
+
let messageBuffer = '';
|
|
560
|
+
let tempFilePaths = [];
|
|
561
|
+
const tempDirs = [];
|
|
562
|
+
// Default: do not hard-require native write_todos.
|
|
563
|
+
// Keep compatibility with markdown/shim flows unless explicitly enabled.
|
|
564
|
+
const requireWriteTodos = Boolean(toolsSettings?.enforceNativeTodos) &&
|
|
565
|
+
(permissionMode === 'plan' || isLikelyMultiStepRequest(command));
|
|
566
|
+
let hasNativeTodoWrite = false;
|
|
567
|
+
const cleanedInitialUserCommand = stripInternalContextPrefix(String(command || ''));
|
|
568
|
+
let initialUserCommandSaved = false;
|
|
569
|
+
let lastSentGeminiErrorSummary = '';
|
|
570
|
+
let policyViolationTriggered = false;
|
|
571
|
+
|
|
572
|
+
const workingDir = cwd || projectPath || process.cwd();
|
|
573
|
+
|
|
574
|
+
// Synchronous (better-sqlite3) — no await needed.
|
|
575
|
+
if (sessionId && workingDir) {
|
|
576
|
+
applyStageTagsToSession({
|
|
577
|
+
sessionId,
|
|
578
|
+
projectPath: workingDir,
|
|
579
|
+
stageTagKeys,
|
|
580
|
+
source: stageTagSource,
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Keep Gemini session bootstrap parity with Claude sessions:
|
|
585
|
+
// ensure skill links and instruction templates exist in project workspace.
|
|
586
|
+
if (workingDir) {
|
|
587
|
+
try {
|
|
588
|
+
await ensureProjectSkillLinks(workingDir);
|
|
589
|
+
await writeProjectTemplates(workingDir);
|
|
590
|
+
} catch (err) {
|
|
591
|
+
console.warn('[gemini-cli] Failed to initialize project skills/templates:', err.message);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// Track allowed/disallowed tools locally for this session
|
|
596
|
+
const sessionAllowedTools = permissionMode === 'plan'
|
|
597
|
+
? ensurePlanModeAllowedTools([...(toolsSettings?.allowedTools || [])])
|
|
598
|
+
: [...(toolsSettings?.allowedTools || [])];
|
|
599
|
+
const sessionDisallowedTools = [...(toolsSettings?.disallowedTools || [])];
|
|
600
|
+
|
|
601
|
+
// Build Gemini CLI command
|
|
602
|
+
const args = [];
|
|
603
|
+
|
|
604
|
+
if (sessionId && !sessionId.startsWith('new-session-')) {
|
|
605
|
+
args.push('--resume', sessionId);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
if (command && command.trim()) {
|
|
609
|
+
const effectiveAttachments = attachments || images;
|
|
610
|
+
const attachmentResult = await handleGeminiAttachments(command, effectiveAttachments, workingDir);
|
|
611
|
+
tempFilePaths = attachmentResult.tempFilePaths;
|
|
612
|
+
if (attachmentResult.tempDir) {
|
|
613
|
+
tempDirs.push(attachmentResult.tempDir);
|
|
614
|
+
}
|
|
615
|
+
const effectivePrompt = `${attachmentResult.modifiedCommand}`;
|
|
616
|
+
args.push('--prompt', effectivePrompt);
|
|
617
|
+
|
|
618
|
+
// Keep Gemini CLI in yolo mode internally and enforce policy in our own approval hook.
|
|
619
|
+
// In non-interactive server sessions, Gemini's own policy prompts can deny tools unexpectedly.
|
|
620
|
+
args.push('--approval-mode', 'yolo');
|
|
621
|
+
|
|
622
|
+
const includeDirectories = [
|
|
623
|
+
path.join(process.cwd(), 'skills'),
|
|
624
|
+
path.join(workingDir, '.agents', 'skills'),
|
|
625
|
+
path.join(workingDir, '.claude', 'skills'),
|
|
626
|
+
path.join(workingDir, '.gemini', 'skills')
|
|
627
|
+
];
|
|
628
|
+
for (const includeDir of includeDirectories) {
|
|
629
|
+
try {
|
|
630
|
+
await fs.access(includeDir);
|
|
631
|
+
args.push('--include-directories', includeDir);
|
|
632
|
+
} catch {
|
|
633
|
+
// Optional include dir, skip if absent.
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Request streaming JSON output
|
|
638
|
+
args.push('--output-format', 'stream-json');
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const geminiCommand = process.env.GEMINI_CLI_PATH || 'gemini';
|
|
642
|
+
|
|
643
|
+
const cleanEnv = { ...(env || process.env) };
|
|
644
|
+
// Non-interactive JSON streaming: avoid terminal renderer hard-wrap artifacts.
|
|
645
|
+
cleanEnv.TERM = 'dumb';
|
|
646
|
+
cleanEnv.COLUMNS = '1000';
|
|
647
|
+
cleanEnv.LINES = '200';
|
|
648
|
+
delete cleanEnv.TERM_PROGRAM;
|
|
649
|
+
delete cleanEnv.TERM_PROGRAM_VERSION;
|
|
650
|
+
delete cleanEnv.ITERM_SESSION_ID;
|
|
651
|
+
|
|
652
|
+
const thinkingSettings = await prepareGeminiThinkingSettings(model, thinkingMode, cleanEnv);
|
|
653
|
+
const effectiveCliModel = thinkingSettings?.cliModel || model;
|
|
654
|
+
if (thinkingSettings?.settingsPath) {
|
|
655
|
+
cleanEnv.GEMINI_CLI_SYSTEM_SETTINGS_PATH = thinkingSettings.settingsPath;
|
|
656
|
+
tempFilePaths.push(thinkingSettings.settingsPath);
|
|
657
|
+
if (thinkingSettings.tempDir) {
|
|
658
|
+
tempDirs.push(thinkingSettings.tempDir);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
if (effectiveCliModel && command && command.trim()) {
|
|
663
|
+
args.splice(2, 0, '--model', effectiveCliModel);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
const escapedArgs = args.map(a => a.includes(' ') ? `"${a.replace(/"/g, '\\"')}"` : a);
|
|
667
|
+
console.log(`[Gemini] Spawning (YOLO-Control): ${geminiCommand} ${escapedArgs.join(' ')}`);
|
|
668
|
+
console.log(`[Gemini] Working directory: ${workingDir}`);
|
|
669
|
+
|
|
670
|
+
let geminiProcess;
|
|
671
|
+
try {
|
|
672
|
+
geminiProcess = spawnFunction(geminiCommand, args, {
|
|
673
|
+
cwd: workingDir,
|
|
674
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
675
|
+
env: cleanEnv,
|
|
676
|
+
detached: process.platform !== 'win32'
|
|
677
|
+
});
|
|
678
|
+
} catch (spawnError) {
|
|
679
|
+
console.error('[Gemini] Spawn call failed:', spawnError);
|
|
680
|
+
ws.send({ type: 'gemini-error', error: `Failed to start Gemini CLI: ${spawnError.message}`, sessionId: capturedSessionId || sessionId || null });
|
|
681
|
+
reject(spawnError);
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
if (!geminiProcess || !geminiProcess.pid) {
|
|
686
|
+
console.error('[Gemini] Process failed to spawn (no PID)');
|
|
687
|
+
ws.send({ type: 'gemini-error', error: 'Failed to start Gemini CLI process', sessionId: capturedSessionId || sessionId || null });
|
|
688
|
+
reject(new Error('Failed to start Gemini CLI process'));
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
console.log(`[Gemini] Process spawned with PID: ${geminiProcess.pid}`);
|
|
693
|
+
|
|
694
|
+
const initialKey = capturedSessionId || `temp-${Date.now()}`;
|
|
695
|
+
const startTimeValue = Date.now();
|
|
696
|
+
|
|
697
|
+
const sessionData = {
|
|
698
|
+
process: geminiProcess,
|
|
699
|
+
sessionId: capturedSessionId,
|
|
700
|
+
startTime: startTimeValue,
|
|
701
|
+
options,
|
|
702
|
+
sessionAllowedTools,
|
|
703
|
+
sessionDisallowedTools
|
|
704
|
+
};
|
|
705
|
+
|
|
706
|
+
const statusHeartbeat = setInterval(() => {
|
|
707
|
+
ws.send({
|
|
708
|
+
type: 'gemini-status',
|
|
709
|
+
data: { status: 'Working...', can_interrupt: true, startTime: sessionData.startTime },
|
|
710
|
+
sessionId: capturedSessionId || sessionId || null
|
|
711
|
+
});
|
|
712
|
+
}, 2000);
|
|
713
|
+
|
|
714
|
+
sessionData.heartbeat = statusHeartbeat;
|
|
715
|
+
activeGeminiSessions.set(initialKey, sessionData);
|
|
716
|
+
|
|
717
|
+
ws.send({
|
|
718
|
+
type: 'gemini-status',
|
|
719
|
+
data: { status: 'Working...', can_interrupt: true, startTime: startTimeValue },
|
|
720
|
+
sessionId: capturedSessionId || sessionId || null
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
const sendLifecycleStart = (id) => {
|
|
724
|
+
if (!messageStartedSent) {
|
|
725
|
+
messageStartedSent = true;
|
|
726
|
+
ws.send({
|
|
727
|
+
type: 'gemini-response',
|
|
728
|
+
data: {
|
|
729
|
+
type: 'message_start',
|
|
730
|
+
message: { id: `msg_gemini_${Date.now()}`, role: 'assistant', content: [], model: model || 'gemini' },
|
|
731
|
+
startTime: sessionData.startTime
|
|
732
|
+
},
|
|
733
|
+
sessionId: id || capturedSessionId || sessionId || null
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
};
|
|
737
|
+
|
|
738
|
+
const sendContentBlockStart = (id, type = 'text', index = 0) => {
|
|
739
|
+
if (type === 'text' && contentBlockStarted) return;
|
|
740
|
+
if (type === 'text') contentBlockStarted = true;
|
|
741
|
+
currentBlockIndex = index;
|
|
742
|
+
ws.send({
|
|
743
|
+
type: 'gemini-response',
|
|
744
|
+
data: {
|
|
745
|
+
type: 'content_block_start',
|
|
746
|
+
index: index,
|
|
747
|
+
content_block: type === 'text' ? { type: 'text', text: '' } : { type: 'tool_use', id: `tool_${Date.now()}`, name: '', input: {} },
|
|
748
|
+
startTime: sessionData.startTime
|
|
749
|
+
},
|
|
750
|
+
sessionId: id || capturedSessionId || sessionId || null
|
|
751
|
+
});
|
|
752
|
+
};
|
|
753
|
+
|
|
754
|
+
const sendContentBlockStop = (index) => {
|
|
755
|
+
ws.send({
|
|
756
|
+
type: 'gemini-response',
|
|
757
|
+
data: {
|
|
758
|
+
type: 'content_block_stop',
|
|
759
|
+
index: index !== undefined ? index : currentBlockIndex
|
|
760
|
+
},
|
|
761
|
+
sessionId: capturedSessionId || sessionId || null
|
|
762
|
+
});
|
|
763
|
+
};
|
|
764
|
+
|
|
765
|
+
const sendGeminiError = (error, details = undefined) => {
|
|
766
|
+
const summary = String(error || '').trim();
|
|
767
|
+
if (!summary || summary === lastSentGeminiErrorSummary) {
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
lastSentGeminiErrorSummary = summary;
|
|
771
|
+
|
|
772
|
+
const { errorType, isRetryable } = classifyError(summary);
|
|
773
|
+
|
|
774
|
+
ws.send({
|
|
775
|
+
type: 'gemini-error',
|
|
776
|
+
error: summary,
|
|
777
|
+
errorType,
|
|
778
|
+
isRetryable,
|
|
779
|
+
...(typeof details === 'string' && details.trim() ? { details } : {}),
|
|
780
|
+
sessionId: capturedSessionId || sessionId || null
|
|
781
|
+
});
|
|
782
|
+
};
|
|
783
|
+
|
|
784
|
+
const handleToolApproval = async (toolName, input, allowedTools = [], disallowedTools = []) => {
|
|
785
|
+
// Internal bypass if UI mode is actually bypass
|
|
786
|
+
if (permissionMode === 'bypassPermissions' || toolsSettings?.skipPermissions === true) return true;
|
|
787
|
+
|
|
788
|
+
const isDisallowed = disallowedTools.some(entry => matchesToolPermission(entry, toolName, input));
|
|
789
|
+
if (isDisallowed) return false;
|
|
790
|
+
|
|
791
|
+
if (permissionMode === 'plan') {
|
|
792
|
+
const allowedInPlan = ensurePlanModeAllowedTools(allowedTools);
|
|
793
|
+
return allowedInPlan.some(entry => matchesToolPermission(entry, toolName, input));
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// Auto Edit Mode: Automatically approve editing tools
|
|
797
|
+
if (permissionMode === 'acceptEdits') {
|
|
798
|
+
const editTools = ['write_file', 'replace', 'insert_content', 'undo'];
|
|
799
|
+
if (editTools.includes(toolName)) {
|
|
800
|
+
console.log(`[Gemini] Auto-approving edit tool: ${toolName}`);
|
|
801
|
+
return true;
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
const isAllowed = allowedTools.some(entry => matchesToolPermission(entry, toolName, input));
|
|
806
|
+
if (isAllowed) return true;
|
|
807
|
+
|
|
808
|
+
const requestId = createRequestId();
|
|
809
|
+
ws.send({
|
|
810
|
+
type: 'claude-permission-request',
|
|
811
|
+
requestId,
|
|
812
|
+
toolName,
|
|
813
|
+
input,
|
|
814
|
+
sessionId: capturedSessionId || sessionId || null
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
const requiresInteraction = GEMINI_INTERACTIVE_TOOLS.has(String(toolName || ''));
|
|
818
|
+
const decision = await waitForToolApproval(requestId, {
|
|
819
|
+
timeoutMs: requiresInteraction ? 0 : undefined,
|
|
820
|
+
onCancel: (reason) => {
|
|
821
|
+
ws.send({
|
|
822
|
+
type: 'claude-permission-cancelled',
|
|
823
|
+
requestId,
|
|
824
|
+
reason,
|
|
825
|
+
sessionId: capturedSessionId || sessionId || null
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
});
|
|
829
|
+
if (!decision || decision.cancelled || !decision.allow) return false;
|
|
830
|
+
|
|
831
|
+
if (decision.rememberEntry && typeof decision.rememberEntry === 'string') {
|
|
832
|
+
if (!allowedTools.includes(decision.rememberEntry)) allowedTools.push(decision.rememberEntry);
|
|
833
|
+
const idx = disallowedTools.indexOf(decision.rememberEntry);
|
|
834
|
+
if (idx !== -1) disallowedTools.splice(idx, 1);
|
|
835
|
+
}
|
|
836
|
+
return true;
|
|
837
|
+
};
|
|
838
|
+
|
|
839
|
+
let processingQueue = Promise.resolve();
|
|
840
|
+
let leftOver = '';
|
|
841
|
+
let hasParsedStructuredOutput = false;
|
|
842
|
+
const toolCallContext = new Map();
|
|
843
|
+
const inferredProjectName = inferProjectName(workingDir, projectPath);
|
|
844
|
+
|
|
845
|
+
const appendToSessionFile = async (sid, entry) => {
|
|
846
|
+
const targetSid = capturedSessionId || sid || sessionId;
|
|
847
|
+
if (!targetSid || targetSid.startsWith('temp-')) return;
|
|
848
|
+
|
|
849
|
+
// If it's still a new-session placeholder, we can't save yet, but we'll try again later
|
|
850
|
+
// or we just rely on the fact that capturedSessionId will be set very quickly.
|
|
851
|
+
if (targetSid.startsWith('new-session-')) {
|
|
852
|
+
// console.log('[Gemini] Postponing save, still have new-session ID');
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
const geminiSessionsDir = path.join(os.homedir(), '.gemini', 'sessions');
|
|
857
|
+
const sessionFile = path.join(geminiSessionsDir, `${targetSid}.jsonl`);
|
|
858
|
+
try {
|
|
859
|
+
await fs.mkdir(geminiSessionsDir, { recursive: true });
|
|
860
|
+
const data = JSON.stringify({ ...entry, timestamp: new Date().toISOString() }) + '\n';
|
|
861
|
+
await fs.appendFile(sessionFile, data, 'utf8');
|
|
862
|
+
console.log(`[Gemini] Saved ${entry.role || entry.type} to ${targetSid}`);
|
|
863
|
+
} catch (error) {
|
|
864
|
+
console.error(`[Gemini] Failed to append to session file: ${error.message}`);
|
|
865
|
+
}
|
|
866
|
+
};
|
|
867
|
+
|
|
868
|
+
const persistAssistantMessageBuffer = async (sid, content) => {
|
|
869
|
+
if (!content) return;
|
|
870
|
+
const normalizedEntries = normalizePersistedGeminiAssistantEntries(content);
|
|
871
|
+
for (const entry of normalizedEntries) {
|
|
872
|
+
await appendToSessionFile(sid, entry);
|
|
873
|
+
}
|
|
874
|
+
};
|
|
875
|
+
|
|
876
|
+
const processLine = async (line) => {
|
|
877
|
+
if (!line.trim()) return;
|
|
878
|
+
if (policyViolationTriggered) return;
|
|
879
|
+
try {
|
|
880
|
+
const response = JSON.parse(line);
|
|
881
|
+
hasParsedStructuredOutput = true;
|
|
882
|
+
switch (response.type) {
|
|
883
|
+
case 'init':
|
|
884
|
+
case 'session':
|
|
885
|
+
const sid = response.session_id || response.id;
|
|
886
|
+
if (sid && (!capturedSessionId || capturedSessionId.startsWith('new-session-') || capturedSessionId.startsWith('temp-'))) {
|
|
887
|
+
const oldKey = capturedSessionId || initialKey;
|
|
888
|
+
capturedSessionId = sid;
|
|
889
|
+
|
|
890
|
+
// Persist metadata to filesystem so Dr. Claw can discover it on refresh
|
|
891
|
+
await syncSessionMetadata(capturedSessionId, workingDir, sessionMode);
|
|
892
|
+
await persistGeminiSessionMetadata(capturedSessionId, workingDir, sessionMode);
|
|
893
|
+
|
|
894
|
+
// NEW: If we have an initial command, save it now that we have a real SID
|
|
895
|
+
if (cleanedInitialUserCommand) {
|
|
896
|
+
await appendToSessionFile(capturedSessionId, {
|
|
897
|
+
role: 'user',
|
|
898
|
+
content: cleanedInitialUserCommand,
|
|
899
|
+
type: 'message'
|
|
900
|
+
});
|
|
901
|
+
initialUserCommandSaved = true;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
if (oldKey !== capturedSessionId) {
|
|
905
|
+
const sessionData = activeGeminiSessions.get(oldKey);
|
|
906
|
+
if (sessionData) {
|
|
907
|
+
activeGeminiSessions.delete(oldKey);
|
|
908
|
+
sessionData.sessionId = capturedSessionId;
|
|
909
|
+
activeGeminiSessions.set(capturedSessionId, sessionData);
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
if (ws.setSessionId && typeof ws.setSessionId === 'function') ws.setSessionId(capturedSessionId);
|
|
913
|
+
if (!sessionCreatedSent) {
|
|
914
|
+
sessionCreatedSent = true;
|
|
915
|
+
recordIndexedSession({
|
|
916
|
+
sessionId: capturedSessionId,
|
|
917
|
+
provider: 'gemini',
|
|
918
|
+
projectPath: workingDir,
|
|
919
|
+
sessionMode: sessionMode || 'research',
|
|
920
|
+
stageTagKeys,
|
|
921
|
+
tagSource: stageTagSource,
|
|
922
|
+
});
|
|
923
|
+
ws.send({ type: 'session-created', sessionId: capturedSessionId, provider: 'gemini', mode: sessionMode || 'research' });
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
break;
|
|
927
|
+
|
|
928
|
+
case 'message':
|
|
929
|
+
if (response.role && response.content) {
|
|
930
|
+
const contentText = typeof response.content === 'string'
|
|
931
|
+
? response.content
|
|
932
|
+
: Array.isArray(response.content)
|
|
933
|
+
? response.content.map((part) => part?.text || (typeof part === 'string' ? part : '')).join('')
|
|
934
|
+
: '';
|
|
935
|
+
|
|
936
|
+
if (response.role === 'assistant' && contentText) {
|
|
937
|
+
sendLifecycleStart();
|
|
938
|
+
sendContentBlockStart(null, 'text', 0);
|
|
939
|
+
messageBuffer += contentText;
|
|
940
|
+
ws.send({
|
|
941
|
+
type: 'gemini-response',
|
|
942
|
+
data: { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: contentText } },
|
|
943
|
+
sessionId: capturedSessionId || sessionId || null
|
|
944
|
+
});
|
|
945
|
+
} else {
|
|
946
|
+
const sanitizedContent = response.role === 'user'
|
|
947
|
+
? sanitizePersistedGeminiContent(response.content)
|
|
948
|
+
: response.content;
|
|
949
|
+
const sanitizedContentText = typeof sanitizedContent === 'string'
|
|
950
|
+
? sanitizedContent
|
|
951
|
+
: Array.isArray(sanitizedContent)
|
|
952
|
+
? sanitizedContent.map((part) => part?.text || (typeof part === 'string' ? part : '')).join('')
|
|
953
|
+
: '';
|
|
954
|
+
if (
|
|
955
|
+
response.role === 'user' &&
|
|
956
|
+
initialUserCommandSaved &&
|
|
957
|
+
cleanedInitialUserCommand &&
|
|
958
|
+
typeof sanitizedContentText === 'string' &&
|
|
959
|
+
sanitizedContentText.trim() === cleanedInitialUserCommand.trim()
|
|
960
|
+
) {
|
|
961
|
+
break;
|
|
962
|
+
}
|
|
963
|
+
await appendToSessionFile(capturedSessionId || sessionId || initialKey, {
|
|
964
|
+
role: response.role,
|
|
965
|
+
content: sanitizedContent,
|
|
966
|
+
type: 'message'
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
break;
|
|
971
|
+
|
|
972
|
+
case 'content':
|
|
973
|
+
case 'chunk':
|
|
974
|
+
const text = response.text || response.content || response.delta;
|
|
975
|
+
if (text && response.role !== 'user') {
|
|
976
|
+
sendLifecycleStart();
|
|
977
|
+
sendContentBlockStart(null, 'text', 0);
|
|
978
|
+
messageBuffer += text;
|
|
979
|
+
ws.send({
|
|
980
|
+
type: 'gemini-response',
|
|
981
|
+
data: { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: text } },
|
|
982
|
+
sessionId: capturedSessionId || sessionId || null
|
|
983
|
+
});
|
|
984
|
+
}
|
|
985
|
+
break;
|
|
986
|
+
|
|
987
|
+
case 'tool_use':
|
|
988
|
+
case 'tool_call':
|
|
989
|
+
case 'call':
|
|
990
|
+
sendLifecycleStart();
|
|
991
|
+
const toolIndex = 1;
|
|
992
|
+
const rawToolName = response.name || response.tool_name;
|
|
993
|
+
const toolName = normalizeGeminiToolName(rawToolName);
|
|
994
|
+
const originalToolInput = response.parameters || response.input || response.arguments;
|
|
995
|
+
const toolInput = normalizeGeminiToolInput(rawToolName, originalToolInput);
|
|
996
|
+
const toolCallId = response.id || `tool_${Date.now()}`;
|
|
997
|
+
const parentToolUseId = response.parent_tool_use_id || response.parentToolUseId || null;
|
|
998
|
+
const rawNameNormalized = String(rawToolName || '').trim();
|
|
999
|
+
const policyTargetName = rawToolName || toolName;
|
|
1000
|
+
|
|
1001
|
+
if (
|
|
1002
|
+
rawNameNormalized.toLowerCase() === 'write_todos' ||
|
|
1003
|
+
rawNameNormalized.toLowerCase() === 'todo_write' ||
|
|
1004
|
+
String(toolName || '').toLowerCase() === 'todowrite'
|
|
1005
|
+
) {
|
|
1006
|
+
hasNativeTodoWrite = true;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
if (requireWriteTodos && !hasNativeTodoWrite && !isAllowedBeforeTodos(rawToolName)) {
|
|
1010
|
+
policyViolationTriggered = true;
|
|
1011
|
+
sendGeminiError('Policy violation: multi-step tasks must call write_todos before executing other tools.');
|
|
1012
|
+
geminiProcess.kill('SIGKILL');
|
|
1013
|
+
break;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
if (permissionMode === 'plan' && GEMINI_PLAN_BLOCKED_TOOLS.has(policyTargetName)) {
|
|
1017
|
+
policyViolationTriggered = true;
|
|
1018
|
+
sendGeminiError(`Tool execution denied by plan policy: ${policyTargetName}`);
|
|
1019
|
+
geminiProcess.kill('SIGKILL');
|
|
1020
|
+
break;
|
|
1021
|
+
}
|
|
1022
|
+
toolCallContext.set(toolCallId, {
|
|
1023
|
+
rawToolName,
|
|
1024
|
+
normalizedToolName: toolName,
|
|
1025
|
+
toolInput,
|
|
1026
|
+
parentToolUseId
|
|
1027
|
+
});
|
|
1028
|
+
|
|
1029
|
+
await appendToSessionFile(capturedSessionId || sessionId || initialKey, {
|
|
1030
|
+
type: 'tool_use',
|
|
1031
|
+
toolName,
|
|
1032
|
+
rawToolName,
|
|
1033
|
+
toolInput,
|
|
1034
|
+
toolCallId,
|
|
1035
|
+
parentToolUseId
|
|
1036
|
+
});
|
|
1037
|
+
|
|
1038
|
+
ws.send({
|
|
1039
|
+
type: 'gemini-response',
|
|
1040
|
+
data: {
|
|
1041
|
+
role: 'assistant',
|
|
1042
|
+
...(parentToolUseId ? { parentToolUseId } : {}),
|
|
1043
|
+
content: [
|
|
1044
|
+
{
|
|
1045
|
+
type: 'tool_use',
|
|
1046
|
+
id: toolCallId,
|
|
1047
|
+
name: toolName,
|
|
1048
|
+
input: toolInput
|
|
1049
|
+
}
|
|
1050
|
+
]
|
|
1051
|
+
},
|
|
1052
|
+
sessionId: capturedSessionId || sessionId || null
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
// Compatibility shim: when Gemini manages todo list via markdown file writes
|
|
1056
|
+
// rather than write_todos tool, synthesize TodoWrite card for UI parity.
|
|
1057
|
+
if ((rawToolName === 'write_file' || rawToolName === 'replace') && typeof toolInput?.file_path === 'string' && /(?:^|\/)\.pipeline\/tasks\/todos\.md$/.test(toolInput.file_path)) {
|
|
1058
|
+
const todos = parseTodosFromMarkdown(String(toolInput?.content || toolInput?.new_string || ''));
|
|
1059
|
+
if (todos.length > 0) {
|
|
1060
|
+
const syntheticId = `todo_${Date.now()}`;
|
|
1061
|
+
ws.send({
|
|
1062
|
+
type: 'gemini-response',
|
|
1063
|
+
data: {
|
|
1064
|
+
role: 'assistant',
|
|
1065
|
+
...(parentToolUseId ? { parentToolUseId } : {}),
|
|
1066
|
+
content: [{ type: 'tool_use', id: syntheticId, name: 'TodoWrite', input: { todos } }]
|
|
1067
|
+
},
|
|
1068
|
+
sessionId: capturedSessionId || sessionId || null
|
|
1069
|
+
});
|
|
1070
|
+
ws.send({
|
|
1071
|
+
type: 'gemini-response',
|
|
1072
|
+
data: {
|
|
1073
|
+
role: 'user',
|
|
1074
|
+
content: [{ type: 'tool_result', tool_use_id: syntheticId, content: 'Todo list updated', is_error: false }]
|
|
1075
|
+
},
|
|
1076
|
+
sessionId: capturedSessionId || sessionId || null
|
|
1077
|
+
});
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
if (rawToolName === 'run_shell_command') {
|
|
1082
|
+
const todos = extractTodosFromShellCommand(String(toolInput?.command || toolInput?.cmd || ''));
|
|
1083
|
+
if (todos.length > 0) {
|
|
1084
|
+
const syntheticId = `todo_${Date.now()}`;
|
|
1085
|
+
ws.send({
|
|
1086
|
+
type: 'gemini-response',
|
|
1087
|
+
data: {
|
|
1088
|
+
role: 'assistant',
|
|
1089
|
+
...(parentToolUseId ? { parentToolUseId } : {}),
|
|
1090
|
+
content: [{ type: 'tool_use', id: syntheticId, name: 'TodoWrite', input: { todos } }]
|
|
1091
|
+
},
|
|
1092
|
+
sessionId: capturedSessionId || sessionId || null
|
|
1093
|
+
});
|
|
1094
|
+
ws.send({
|
|
1095
|
+
type: 'gemini-response',
|
|
1096
|
+
data: {
|
|
1097
|
+
role: 'user',
|
|
1098
|
+
content: [{ type: 'tool_result', tool_use_id: syntheticId, content: 'Todo list updated', is_error: false }]
|
|
1099
|
+
},
|
|
1100
|
+
sessionId: capturedSessionId || sessionId || null
|
|
1101
|
+
});
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
ws.send({
|
|
1106
|
+
type: 'gemini-response',
|
|
1107
|
+
data: {
|
|
1108
|
+
type: 'content_block_start',
|
|
1109
|
+
index: toolIndex,
|
|
1110
|
+
content_block: { id: toolCallId, name: toolName, input: toolInput }
|
|
1111
|
+
},
|
|
1112
|
+
sessionId: capturedSessionId || sessionId || null
|
|
1113
|
+
});
|
|
1114
|
+
currentBlockIndex = toolIndex;
|
|
1115
|
+
|
|
1116
|
+
const currentSessionData = activeGeminiSessions.get(capturedSessionId || initialKey);
|
|
1117
|
+
const approved = await handleToolApproval(
|
|
1118
|
+
rawToolName || toolName,
|
|
1119
|
+
toolInput,
|
|
1120
|
+
currentSessionData?.sessionAllowedTools || sessionAllowedTools,
|
|
1121
|
+
currentSessionData?.sessionDisallowedTools || sessionDisallowedTools
|
|
1122
|
+
);
|
|
1123
|
+
|
|
1124
|
+
if (!approved) {
|
|
1125
|
+
ws.send({
|
|
1126
|
+
type: 'gemini-error',
|
|
1127
|
+
error: `Tool '${toolName}' was denied by user. Aborting session for safety.`,
|
|
1128
|
+
sessionId: capturedSessionId || sessionId || null
|
|
1129
|
+
});
|
|
1130
|
+
geminiProcess.kill('SIGKILL');
|
|
1131
|
+
}
|
|
1132
|
+
break;
|
|
1133
|
+
|
|
1134
|
+
case 'tool_result':
|
|
1135
|
+
if (response.output || response.content) {
|
|
1136
|
+
const rawResult = response.output !== undefined ? response.output : response.content;
|
|
1137
|
+
const baseOutputText = typeof rawResult === 'string' ? rawResult : JSON.stringify(rawResult, null, 2);
|
|
1138
|
+
const resultToolCallId = response.id || response.tool_use_id;
|
|
1139
|
+
const ctx = resultToolCallId ? toolCallContext.get(resultToolCallId) : null;
|
|
1140
|
+
const isError = Boolean(response.is_error || response.error);
|
|
1141
|
+
const outputText = await enrichListDirectoryResult(ctx, baseOutputText, workingDir);
|
|
1142
|
+
|
|
1143
|
+
await appendToSessionFile(capturedSessionId || sessionId || initialKey, {
|
|
1144
|
+
type: 'tool_result',
|
|
1145
|
+
output: outputText,
|
|
1146
|
+
toolCallId: resultToolCallId,
|
|
1147
|
+
parentToolUseId: ctx?.parentToolUseId || null,
|
|
1148
|
+
isError
|
|
1149
|
+
});
|
|
1150
|
+
|
|
1151
|
+
if (resultToolCallId) {
|
|
1152
|
+
ws.send({
|
|
1153
|
+
type: 'gemini-response',
|
|
1154
|
+
data: {
|
|
1155
|
+
role: 'user',
|
|
1156
|
+
...(ctx?.parentToolUseId ? { parentToolUseId: ctx.parentToolUseId } : {}),
|
|
1157
|
+
content: [
|
|
1158
|
+
{
|
|
1159
|
+
type: 'tool_result',
|
|
1160
|
+
tool_use_id: resultToolCallId,
|
|
1161
|
+
content: outputText,
|
|
1162
|
+
is_error: isError
|
|
1163
|
+
}
|
|
1164
|
+
]
|
|
1165
|
+
},
|
|
1166
|
+
sessionId: capturedSessionId || sessionId || null
|
|
1167
|
+
});
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
if (shouldNotifyTaskMasterRefresh(ctx?.rawToolName, ctx?.toolInput, outputText) && inferredProjectName) {
|
|
1171
|
+
ws.send({
|
|
1172
|
+
type: 'taskmaster-tasks-updated',
|
|
1173
|
+
projectName: inferredProjectName,
|
|
1174
|
+
provider: 'gemini',
|
|
1175
|
+
source: 'gemini-tool-result',
|
|
1176
|
+
timestamp: new Date().toISOString()
|
|
1177
|
+
});
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
if (shouldNotifyProjectRefresh(ctx?.rawToolName, ctx?.toolInput, outputText) && inferredProjectName) {
|
|
1181
|
+
ws.send({
|
|
1182
|
+
type: 'taskmaster-project-updated',
|
|
1183
|
+
projectName: inferredProjectName,
|
|
1184
|
+
provider: 'gemini',
|
|
1185
|
+
source: 'gemini-tool-result',
|
|
1186
|
+
timestamp: new Date().toISOString()
|
|
1187
|
+
});
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
if (resultToolCallId) {
|
|
1191
|
+
toolCallContext.delete(resultToolCallId);
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
sendContentBlockStop(1);
|
|
1195
|
+
currentBlockIndex = 0;
|
|
1196
|
+
break;
|
|
1197
|
+
|
|
1198
|
+
case 'result':
|
|
1199
|
+
if (messageBuffer && (capturedSessionId || sessionId)) {
|
|
1200
|
+
await persistAssistantMessageBuffer(capturedSessionId || sessionId || initialKey, messageBuffer);
|
|
1201
|
+
messageBuffer = '';
|
|
1202
|
+
}
|
|
1203
|
+
break;
|
|
1204
|
+
case 'status':
|
|
1205
|
+
if (response.stats || response.status === 'completed') {
|
|
1206
|
+
if (response.stats) {
|
|
1207
|
+
await appendToSessionFile(capturedSessionId || sessionId || initialKey, {
|
|
1208
|
+
type: 'status',
|
|
1209
|
+
stats: response.stats
|
|
1210
|
+
});
|
|
1211
|
+
}
|
|
1212
|
+
if (response.stats) {
|
|
1213
|
+
ws.send({
|
|
1214
|
+
type: 'token-budget',
|
|
1215
|
+
data: {
|
|
1216
|
+
used: response.stats.total_tokens || response.stats.input_tokens + response.stats.output_tokens,
|
|
1217
|
+
total: GEMINI_DEFAULT_CONTEXT_WINDOW,
|
|
1218
|
+
breakdown: {
|
|
1219
|
+
input: response.stats.input_tokens || 0,
|
|
1220
|
+
output: response.stats.output_tokens || 0,
|
|
1221
|
+
cacheCreation: response.stats.cache_creation_input_tokens || 0,
|
|
1222
|
+
cacheRead: response.stats.cache_read_input_tokens || 0
|
|
1223
|
+
}
|
|
1224
|
+
},
|
|
1225
|
+
sessionId: capturedSessionId || sessionId || null
|
|
1226
|
+
});
|
|
1227
|
+
}
|
|
1228
|
+
sendContentBlockStop();
|
|
1229
|
+
ws.send({ type: 'gemini-response', data: { type: 'message_delta', delta: { stop_reason: 'end_turn' } }, sessionId: capturedSessionId || sessionId || null });
|
|
1230
|
+
ws.send({ type: 'gemini-response', data: { type: 'message_stop' }, sessionId: capturedSessionId || sessionId || null });
|
|
1231
|
+
}
|
|
1232
|
+
break;
|
|
1233
|
+
|
|
1234
|
+
case 'error':
|
|
1235
|
+
sendGeminiError(response.message);
|
|
1236
|
+
break;
|
|
1237
|
+
}
|
|
1238
|
+
} catch (parseError) {
|
|
1239
|
+
// Gemini stream-json should be JSONL. Ignore non-JSON diagnostics to avoid
|
|
1240
|
+
// polluting assistant markdown with hard-wrapped or noisy lines.
|
|
1241
|
+
if (!hasParsedStructuredOutput && line.trim()) {
|
|
1242
|
+
console.log(`[Gemini] Ignoring non-JSON stdout line before structured stream: ${line}`);
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
};
|
|
1246
|
+
|
|
1247
|
+
geminiProcess.stdout.on('data', (data) => {
|
|
1248
|
+
const rawOutput = leftOver + data.toString();
|
|
1249
|
+
const lines = rawOutput.split('\n');
|
|
1250
|
+
leftOver = lines.pop() || '';
|
|
1251
|
+
for (const line of lines) {
|
|
1252
|
+
processingQueue = processingQueue.then(() => processLine(line));
|
|
1253
|
+
}
|
|
1254
|
+
});
|
|
1255
|
+
|
|
1256
|
+
geminiProcess.stderr.on('data', (data) => {
|
|
1257
|
+
const errorOutput = data.toString();
|
|
1258
|
+
if (policyViolationTriggered && /Policy violation: multi-step tasks must call write_todos/i.test(errorOutput)) {
|
|
1259
|
+
return;
|
|
1260
|
+
}
|
|
1261
|
+
if (/error|exception|fail|invalid|denied/i.test(errorOutput) && !/cached credentials/i.test(errorOutput)) {
|
|
1262
|
+
console.error(`[Gemini STDERR] ${errorOutput}`);
|
|
1263
|
+
const parsed = summarizeGeminiErrorOutput(errorOutput, model);
|
|
1264
|
+
sendGeminiError(parsed.summary, parsed.details);
|
|
1265
|
+
}
|
|
1266
|
+
});
|
|
1267
|
+
|
|
1268
|
+
geminiProcess.on('close', async (code) => {
|
|
1269
|
+
await processingQueue;
|
|
1270
|
+
if (messageBuffer && (capturedSessionId || sessionId || initialKey)) {
|
|
1271
|
+
await persistAssistantMessageBuffer(capturedSessionId || sessionId || initialKey, messageBuffer);
|
|
1272
|
+
messageBuffer = '';
|
|
1273
|
+
}
|
|
1274
|
+
const finalSessionId = capturedSessionId || sessionId || initialKey;
|
|
1275
|
+
const sessionData = activeGeminiSessions.get(finalSessionId);
|
|
1276
|
+
if (sessionData?.heartbeat) clearInterval(sessionData.heartbeat);
|
|
1277
|
+
await cleanupGeminiTempFiles(tempFilePaths, tempDirs);
|
|
1278
|
+
// Send completion event immediately so the UI can settle
|
|
1279
|
+
ws.send({ type: 'gemini-complete', sessionId: finalSessionId, exitCode: code, isNewSession: (!sessionId || sessionId.startsWith('new-session-')) && !!command });
|
|
1280
|
+
activeGeminiSessions.delete(finalSessionId);
|
|
1281
|
+
// Post-completion housekeeping — runs after the UI receives the completion signal
|
|
1282
|
+
if (workingDir && finalSessionId) {
|
|
1283
|
+
try {
|
|
1284
|
+
await reconcileGeminiSessionIndex(workingDir, {
|
|
1285
|
+
sessionId: finalSessionId,
|
|
1286
|
+
projectName: encodeProjectPath(workingDir),
|
|
1287
|
+
});
|
|
1288
|
+
} catch (error) {
|
|
1289
|
+
console.warn(`[Gemini] Failed to reconcile indexed session ${finalSessionId}:`, error.message);
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
if (policyViolationTriggered || code === 0 || code === null) resolve();
|
|
1293
|
+
else reject(new Error(`Gemini CLI exited with code ${code}`));
|
|
1294
|
+
});
|
|
1295
|
+
|
|
1296
|
+
geminiProcess.on('error', (error) => {
|
|
1297
|
+
console.error('[Gemini] Process error:', error);
|
|
1298
|
+
const finalSessionId = capturedSessionId || sessionId || initialKey;
|
|
1299
|
+
const sessionData = activeGeminiSessions.get(finalSessionId);
|
|
1300
|
+
if (sessionData && sessionData.heartbeat) clearInterval(sessionData.heartbeat);
|
|
1301
|
+
activeGeminiSessions.delete(finalSessionId);
|
|
1302
|
+
cleanupGeminiTempFiles(tempFilePaths, tempDirs).catch(() => {});
|
|
1303
|
+
sendGeminiError(error.message);
|
|
1304
|
+
reject(error);
|
|
1305
|
+
});
|
|
1306
|
+
geminiProcess.stdin.end();
|
|
1307
|
+
});
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
export function abortGeminiSession(sessionId) {
|
|
1311
|
+
let sessionData = activeGeminiSessions.get(sessionId);
|
|
1312
|
+
let targetId = sessionId;
|
|
1313
|
+
if (!sessionData) {
|
|
1314
|
+
const activeIds = Array.from(activeGeminiSessions.keys());
|
|
1315
|
+
if (activeIds.length === 1) {
|
|
1316
|
+
targetId = activeIds[0];
|
|
1317
|
+
sessionData = activeGeminiSessions.get(targetId);
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
if (sessionData?.process) {
|
|
1321
|
+
try {
|
|
1322
|
+
if (sessionData.heartbeat) { clearInterval(sessionData.heartbeat); sessionData.heartbeat = null; }
|
|
1323
|
+
const proc = sessionData.process;
|
|
1324
|
+
if (process.platform !== 'win32' && proc.pid) {
|
|
1325
|
+
try { process.kill(-proc.pid, 'SIGINT'); } catch (e) { proc.kill('SIGINT'); }
|
|
1326
|
+
} else { proc.kill('SIGINT'); }
|
|
1327
|
+
setTimeout(() => {
|
|
1328
|
+
if (activeGeminiSessions.has(targetId)) {
|
|
1329
|
+
if (process.platform !== 'win32' && proc.pid) { try { process.kill(-proc.pid, 'SIGKILL'); } catch (e) { proc.kill('SIGKILL'); } }
|
|
1330
|
+
else { proc.kill('SIGKILL'); }
|
|
1331
|
+
activeGeminiSessions.delete(targetId);
|
|
1332
|
+
}
|
|
1333
|
+
}, 500);
|
|
1334
|
+
return true;
|
|
1335
|
+
} catch (err) {
|
|
1336
|
+
activeGeminiSessions.delete(targetId);
|
|
1337
|
+
return false;
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
return false;
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
export function isGeminiSessionActive(sessionId) { return activeGeminiSessions.has(sessionId); }
|
|
1344
|
+
|
|
1345
|
+
export function getGeminiSessionStartTime(sessionId) {
|
|
1346
|
+
const session = activeGeminiSessions.get(sessionId);
|
|
1347
|
+
return session ? session.startTime : null;
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
export function getActiveGeminiSessions() {
|
|
1351
|
+
return Array.from(activeGeminiSessions.keys());
|
|
1352
|
+
}
|