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,860 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local GPU Provider — Run open-source models via Ollama
|
|
3
|
+
* =======================================================
|
|
4
|
+
*
|
|
5
|
+
* Connects to a local Ollama server to run models on the user's own GPU.
|
|
6
|
+
* Uses the OpenAI-compatible chat completions API that Ollama exposes.
|
|
7
|
+
* Reuses the same agentic tool loop as OpenRouter (file I/O, shell, search).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import crypto from 'crypto';
|
|
11
|
+
import { promises as fs } from 'fs';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import os from 'os';
|
|
14
|
+
import { exec } from 'child_process';
|
|
15
|
+
import { promisify } from 'util';
|
|
16
|
+
import { encodeProjectPath, ensureProjectSkillLinks, reconcileLocalGPUSessionIndex } from './projects.js';
|
|
17
|
+
import { writeProjectTemplates } from './templates/index.js';
|
|
18
|
+
import { classifyError } from '../shared/errorClassifier.js';
|
|
19
|
+
import { applyStageTagsToSession, recordIndexedSession } from './utils/sessionIndex.js';
|
|
20
|
+
import { createRequestId, waitForToolApproval, matchesToolPermission } from './utils/permissions.js';
|
|
21
|
+
|
|
22
|
+
const execAsync = promisify(exec);
|
|
23
|
+
|
|
24
|
+
const DEFAULT_OLLAMA_URL = 'http://localhost:11434';
|
|
25
|
+
const MAX_AGENT_TURNS = 30;
|
|
26
|
+
const BASH_TIMEOUT_MS = 120_000;
|
|
27
|
+
const MAX_OUTPUT_CHARS = 100_000;
|
|
28
|
+
const SESSIONS_DIR_NAME = 'localgpu-sessions';
|
|
29
|
+
|
|
30
|
+
const activeLocalGPUSessions = new Map();
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// GPU Detection
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
export async function detectGPUs() {
|
|
37
|
+
const result = { gpus: [], system: {} };
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const { stdout } = await execAsync(
|
|
41
|
+
'nvidia-smi --query-gpu=index,name,memory.total,memory.used,utilization.gpu,temperature.gpu --format=csv,noheader,nounits',
|
|
42
|
+
{ timeout: 10000 },
|
|
43
|
+
);
|
|
44
|
+
const lines = stdout.trim().split('\n').filter(Boolean);
|
|
45
|
+
for (const line of lines) {
|
|
46
|
+
const [index, name, memTotal, memUsed, util, temp] = line.split(',').map((s) => s.trim());
|
|
47
|
+
result.gpus.push({
|
|
48
|
+
id: `gpu-${index}`,
|
|
49
|
+
index: parseInt(index, 10),
|
|
50
|
+
name,
|
|
51
|
+
memory: `${memUsed} / ${memTotal} MiB`,
|
|
52
|
+
memoryTotal: parseInt(memTotal, 10),
|
|
53
|
+
memoryUsed: parseInt(memUsed, 10),
|
|
54
|
+
utilization: parseInt(util, 10),
|
|
55
|
+
temperature: parseInt(temp, 10),
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const { stdout: driverOut } = await execAsync('nvidia-smi --query-gpu=driver_version --format=csv,noheader', { timeout: 5000 });
|
|
61
|
+
result.system.driver = driverOut.trim().split('\n')[0];
|
|
62
|
+
} catch {}
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
const { stdout: cudaOut } = await execAsync('nvcc --version 2>/dev/null | grep release', { timeout: 5000 });
|
|
66
|
+
const match = cudaOut.match(/release ([\d.]+)/);
|
|
67
|
+
if (match) result.system.cuda = match[1];
|
|
68
|
+
} catch {}
|
|
69
|
+
} catch {
|
|
70
|
+
// nvidia-smi not available — try macOS (Apple Silicon)
|
|
71
|
+
try {
|
|
72
|
+
const { stdout } = await execAsync('system_profiler SPDisplaysDataType -json', { timeout: 10000 });
|
|
73
|
+
const data = JSON.parse(stdout);
|
|
74
|
+
const displays = data?.SPDisplaysDataType || [];
|
|
75
|
+
for (const [idx, gpu] of displays.entries()) {
|
|
76
|
+
const name = gpu.sppci_model || gpu._name || 'Unknown GPU';
|
|
77
|
+
const vram = gpu.sppci_vram || gpu.spdisplays_vram || 'N/A';
|
|
78
|
+
result.gpus.push({
|
|
79
|
+
id: `gpu-${idx}`,
|
|
80
|
+
index: idx,
|
|
81
|
+
name,
|
|
82
|
+
memory: typeof vram === 'string' ? vram : `${vram} MB`,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
result.system.platform = 'apple_silicon';
|
|
86
|
+
} catch {}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// Ollama helpers
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
function getOllamaUrl(options) {
|
|
97
|
+
return (options?.serverUrl || process.env.LOCAL_GPU_SERVER_URL || DEFAULT_OLLAMA_URL).replace(/\/+$/, '');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function checkOllamaStatus(serverUrl) {
|
|
101
|
+
const url = (serverUrl || DEFAULT_OLLAMA_URL).replace(/\/+$/, '');
|
|
102
|
+
try {
|
|
103
|
+
const res = await fetch(`${url}/api/tags`, { signal: AbortSignal.timeout(5000) });
|
|
104
|
+
if (!res.ok) return { running: false, error: `Server returned ${res.status}` };
|
|
105
|
+
const data = await res.json();
|
|
106
|
+
return { running: true, models: (data.models || []).map(formatOllamaModel) };
|
|
107
|
+
} catch (err) {
|
|
108
|
+
return { running: false, error: err.message };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function formatOllamaModel(m) {
|
|
113
|
+
const sizeMatch = m.details?.parameter_size?.match(/([\d.]+)([BM])/i);
|
|
114
|
+
let sizeB = null;
|
|
115
|
+
if (sizeMatch) {
|
|
116
|
+
sizeB = sizeMatch[2].toUpperCase() === 'B'
|
|
117
|
+
? parseFloat(sizeMatch[1])
|
|
118
|
+
: parseFloat(sizeMatch[1]) / 1000;
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
name: m.name,
|
|
122
|
+
displayName: m.name.split(':')[0],
|
|
123
|
+
size: m.details?.parameter_size || null,
|
|
124
|
+
sizeB,
|
|
125
|
+
family: m.details?.family || null,
|
|
126
|
+
quantization: m.details?.quantization_level || null,
|
|
127
|
+
modifiedAt: m.modified_at,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function pullOllamaModel(serverUrl, modelName) {
|
|
132
|
+
const url = (serverUrl || DEFAULT_OLLAMA_URL).replace(/\/+$/, '');
|
|
133
|
+
const res = await fetch(`${url}/api/pull`, {
|
|
134
|
+
method: 'POST',
|
|
135
|
+
headers: { 'Content-Type': 'application/json' },
|
|
136
|
+
body: JSON.stringify({ name: modelName, stream: false }),
|
|
137
|
+
signal: AbortSignal.timeout(600_000),
|
|
138
|
+
});
|
|
139
|
+
if (!res.ok) {
|
|
140
|
+
const body = await res.text();
|
|
141
|
+
throw new Error(`Failed to pull model: ${body}`);
|
|
142
|
+
}
|
|
143
|
+
return res.json();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
// Tool schemas (same as OpenRouter — OpenAI function-calling format)
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
const TOOL_SCHEMAS = [
|
|
151
|
+
{
|
|
152
|
+
type: 'function',
|
|
153
|
+
function: {
|
|
154
|
+
name: 'Read',
|
|
155
|
+
description: 'Read the contents of a file at the given path. Returns numbered lines.',
|
|
156
|
+
parameters: {
|
|
157
|
+
type: 'object',
|
|
158
|
+
properties: {
|
|
159
|
+
path: { type: 'string', description: 'Absolute or project-relative file path' },
|
|
160
|
+
offset: { type: 'integer', description: 'Start line (1-indexed). Omit to read from the beginning.' },
|
|
161
|
+
limit: { type: 'integer', description: 'Max lines to return. Omit to read the whole file.' },
|
|
162
|
+
},
|
|
163
|
+
required: ['path'],
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
type: 'function',
|
|
169
|
+
function: {
|
|
170
|
+
name: 'Write',
|
|
171
|
+
description: 'Create or overwrite a file with the given contents.',
|
|
172
|
+
parameters: {
|
|
173
|
+
type: 'object',
|
|
174
|
+
properties: {
|
|
175
|
+
path: { type: 'string', description: 'Absolute or project-relative file path' },
|
|
176
|
+
contents: { type: 'string', description: 'Full file contents to write' },
|
|
177
|
+
},
|
|
178
|
+
required: ['path', 'contents'],
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
type: 'function',
|
|
184
|
+
function: {
|
|
185
|
+
name: 'Edit',
|
|
186
|
+
description: 'Replace an exact string in a file with a new string.',
|
|
187
|
+
parameters: {
|
|
188
|
+
type: 'object',
|
|
189
|
+
properties: {
|
|
190
|
+
path: { type: 'string' },
|
|
191
|
+
old_string: { type: 'string', description: 'The exact text to find (must be unique in the file)' },
|
|
192
|
+
new_string: { type: 'string', description: 'The replacement text' },
|
|
193
|
+
},
|
|
194
|
+
required: ['path', 'old_string', 'new_string'],
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
type: 'function',
|
|
200
|
+
function: {
|
|
201
|
+
name: 'Bash',
|
|
202
|
+
description: 'Execute a shell command and return stdout + stderr.',
|
|
203
|
+
parameters: {
|
|
204
|
+
type: 'object',
|
|
205
|
+
properties: {
|
|
206
|
+
command: { type: 'string', description: 'The shell command to run' },
|
|
207
|
+
cwd: { type: 'string', description: 'Working directory (optional)' },
|
|
208
|
+
},
|
|
209
|
+
required: ['command'],
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
type: 'function',
|
|
215
|
+
function: {
|
|
216
|
+
name: 'Glob',
|
|
217
|
+
description: 'Find files matching a glob pattern. Returns matching file paths.',
|
|
218
|
+
parameters: {
|
|
219
|
+
type: 'object',
|
|
220
|
+
properties: {
|
|
221
|
+
pattern: { type: 'string', description: 'Glob pattern, e.g. "**/*.py"' },
|
|
222
|
+
directory: { type: 'string', description: 'Directory to search in (optional)' },
|
|
223
|
+
},
|
|
224
|
+
required: ['pattern'],
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
type: 'function',
|
|
230
|
+
function: {
|
|
231
|
+
name: 'Search',
|
|
232
|
+
description: 'Search file contents for a regex pattern using ripgrep.',
|
|
233
|
+
parameters: {
|
|
234
|
+
type: 'object',
|
|
235
|
+
properties: {
|
|
236
|
+
pattern: { type: 'string', description: 'Regex pattern' },
|
|
237
|
+
directory: { type: 'string', description: 'Directory to search in (optional)' },
|
|
238
|
+
include: { type: 'string', description: 'Glob to filter files, e.g. "*.ts"' },
|
|
239
|
+
},
|
|
240
|
+
required: ['pattern'],
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
type: 'function',
|
|
246
|
+
function: {
|
|
247
|
+
name: 'LS',
|
|
248
|
+
description: 'List files and directories in a directory.',
|
|
249
|
+
parameters: {
|
|
250
|
+
type: 'object',
|
|
251
|
+
properties: {
|
|
252
|
+
path: { type: 'string', description: 'Directory path to list' },
|
|
253
|
+
},
|
|
254
|
+
required: ['path'],
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
];
|
|
259
|
+
|
|
260
|
+
const READ_ONLY_TOOLS = new Set(['Read', 'Glob', 'Search', 'LS']);
|
|
261
|
+
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
// WebSocket helper
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
function sendMessage(ws, data) {
|
|
267
|
+
try {
|
|
268
|
+
if (ws.isSSEStreamWriter || ws.isWebSocketWriter) {
|
|
269
|
+
ws.send(data);
|
|
270
|
+
} else if (typeof ws.send === 'function') {
|
|
271
|
+
ws.send(JSON.stringify(data));
|
|
272
|
+
}
|
|
273
|
+
} catch (error) {
|
|
274
|
+
console.error('[LocalGPU] Error sending message:', error.message);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ---------------------------------------------------------------------------
|
|
279
|
+
// Tool execution (same as OpenRouter)
|
|
280
|
+
// ---------------------------------------------------------------------------
|
|
281
|
+
|
|
282
|
+
async function executeTool(name, args, cwd) {
|
|
283
|
+
try {
|
|
284
|
+
if (name === 'Read') {
|
|
285
|
+
const filePath = path.isAbsolute(args.path) ? args.path : path.resolve(cwd, args.path);
|
|
286
|
+
const raw = await fs.readFile(filePath, 'utf-8');
|
|
287
|
+
const lines = raw.split('\n');
|
|
288
|
+
const start = Math.max(0, (args.offset || 1) - 1);
|
|
289
|
+
const end = args.limit ? start + args.limit : lines.length;
|
|
290
|
+
return lines
|
|
291
|
+
.slice(start, end)
|
|
292
|
+
.map((l, i) => `${start + i + 1}|${l}`)
|
|
293
|
+
.join('\n')
|
|
294
|
+
.slice(0, MAX_OUTPUT_CHARS);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (name === 'Write') {
|
|
298
|
+
const filePath = path.isAbsolute(args.path) ? args.path : path.resolve(cwd, args.path);
|
|
299
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
300
|
+
await fs.writeFile(filePath, args.contents, 'utf-8');
|
|
301
|
+
return `File written: ${filePath}`;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (name === 'Edit') {
|
|
305
|
+
const filePath = path.isAbsolute(args.path) ? args.path : path.resolve(cwd, args.path);
|
|
306
|
+
const raw = await fs.readFile(filePath, 'utf-8');
|
|
307
|
+
if (!raw.includes(args.old_string)) return `Error: old_string not found in ${filePath}`;
|
|
308
|
+
const count = raw.split(args.old_string).length - 1;
|
|
309
|
+
if (count > 1) return `Error: old_string found ${count} times in ${filePath} — must be unique`;
|
|
310
|
+
await fs.writeFile(filePath, raw.replace(args.old_string, args.new_string), 'utf-8');
|
|
311
|
+
return `File edited: ${filePath}`;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (name === 'Bash') {
|
|
315
|
+
const execCwd = args.cwd ? (path.isAbsolute(args.cwd) ? args.cwd : path.resolve(cwd, args.cwd)) : cwd;
|
|
316
|
+
const { stdout, stderr } = await execAsync(args.command, {
|
|
317
|
+
cwd: execCwd,
|
|
318
|
+
timeout: BASH_TIMEOUT_MS,
|
|
319
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
320
|
+
});
|
|
321
|
+
return (stdout + (stderr ? `\n[stderr]\n${stderr}` : '')).slice(0, MAX_OUTPUT_CHARS);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (name === 'Glob') {
|
|
325
|
+
const dir = args.directory
|
|
326
|
+
? (path.isAbsolute(args.directory) ? args.directory : path.resolve(cwd, args.directory))
|
|
327
|
+
: cwd;
|
|
328
|
+
const { stdout } = await execAsync(
|
|
329
|
+
`find ${JSON.stringify(dir)} -path ${JSON.stringify(args.pattern)} -type f 2>/dev/null | head -200`,
|
|
330
|
+
{ cwd: dir, timeout: 15000 },
|
|
331
|
+
);
|
|
332
|
+
return stdout.trim() || 'No files matched.';
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (name === 'Search') {
|
|
336
|
+
const dir = args.directory
|
|
337
|
+
? (path.isAbsolute(args.directory) ? args.directory : path.resolve(cwd, args.directory))
|
|
338
|
+
: cwd;
|
|
339
|
+
const includeFlag = args.include ? `--glob ${JSON.stringify(args.include)}` : '';
|
|
340
|
+
const { stdout } = await execAsync(
|
|
341
|
+
`rg --max-count 50 --line-number ${includeFlag} ${JSON.stringify(args.pattern)} ${JSON.stringify(dir)} 2>/dev/null | head -200`,
|
|
342
|
+
{ cwd: dir, timeout: 15000 },
|
|
343
|
+
);
|
|
344
|
+
return stdout.trim() || 'No results.';
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (name === 'LS') {
|
|
348
|
+
const dir = path.isAbsolute(args.path) ? args.path : path.resolve(cwd, args.path);
|
|
349
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
350
|
+
return entries
|
|
351
|
+
.map((e) => `${e.isDirectory() ? '[DIR] ' : ''}${e.name}`)
|
|
352
|
+
.join('\n')
|
|
353
|
+
.slice(0, MAX_OUTPUT_CHARS);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return `Error: Unknown tool "${name}"`;
|
|
357
|
+
} catch (error) {
|
|
358
|
+
return `Error: ${error.message}`;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ---------------------------------------------------------------------------
|
|
363
|
+
// Permission checking (reuse OpenRouter pattern)
|
|
364
|
+
// ---------------------------------------------------------------------------
|
|
365
|
+
|
|
366
|
+
async function checkToolPermission(
|
|
367
|
+
toolName, args, permissionMode, allowedTools, disallowedTools, ws, sessionId, skipAll,
|
|
368
|
+
) {
|
|
369
|
+
if (skipAll || permissionMode === 'bypassPermissions') return true;
|
|
370
|
+
if (READ_ONLY_TOOLS.has(toolName)) return true;
|
|
371
|
+
if (permissionMode === 'plan') return false;
|
|
372
|
+
|
|
373
|
+
for (const pattern of disallowedTools) {
|
|
374
|
+
if (matchesToolPermission(toolName, args, pattern)) return false;
|
|
375
|
+
}
|
|
376
|
+
for (const pattern of allowedTools) {
|
|
377
|
+
if (matchesToolPermission(toolName, args, pattern)) return true;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (permissionMode === 'acceptEdits' && (toolName === 'Write' || toolName === 'Edit')) return true;
|
|
381
|
+
|
|
382
|
+
const requestId = createRequestId();
|
|
383
|
+
sendMessage(ws, {
|
|
384
|
+
type: 'claude-permission-request',
|
|
385
|
+
requestId,
|
|
386
|
+
sessionId,
|
|
387
|
+
tool: toolName,
|
|
388
|
+
input: args,
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
const decision = await waitForToolApproval(requestId, 300_000);
|
|
392
|
+
return decision?.allow === true;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ---------------------------------------------------------------------------
|
|
396
|
+
// Session persistence (JSONL)
|
|
397
|
+
// ---------------------------------------------------------------------------
|
|
398
|
+
|
|
399
|
+
async function sessionsDir() {
|
|
400
|
+
const dir = path.join(os.homedir(), '.dr-claw', SESSIONS_DIR_NAME);
|
|
401
|
+
await fs.mkdir(dir, { recursive: true });
|
|
402
|
+
return dir;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async function appendSession(sessionId, entry) {
|
|
406
|
+
if (!sessionId || sessionId.startsWith('new-session-') || sessionId.startsWith('temp-')) return;
|
|
407
|
+
const dir = await sessionsDir();
|
|
408
|
+
await fs.appendFile(
|
|
409
|
+
path.join(dir, `${sessionId}.jsonl`),
|
|
410
|
+
JSON.stringify({ ...entry, ts: new Date().toISOString() }) + '\n',
|
|
411
|
+
'utf-8',
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async function loadHistory(sessionId) {
|
|
416
|
+
if (!sessionId) return [];
|
|
417
|
+
try {
|
|
418
|
+
const dir = await sessionsDir();
|
|
419
|
+
const raw = await fs.readFile(path.join(dir, `${sessionId}.jsonl`), 'utf-8');
|
|
420
|
+
const msgs = [];
|
|
421
|
+
for (const line of raw.trim().split('\n').filter(Boolean)) {
|
|
422
|
+
const e = JSON.parse(line);
|
|
423
|
+
if (e.role === 'user') msgs.push({ role: 'user', content: e.content });
|
|
424
|
+
else if (e.role === 'assistant') {
|
|
425
|
+
const msg = { role: 'assistant', content: e.content || null };
|
|
426
|
+
if (e.tool_calls) msg.tool_calls = e.tool_calls;
|
|
427
|
+
msgs.push(msg);
|
|
428
|
+
} else if (e.role === 'tool') {
|
|
429
|
+
msgs.push({ role: 'tool', tool_call_id: e.tool_call_id, content: e.content });
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return msgs;
|
|
433
|
+
} catch {
|
|
434
|
+
return [];
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ---------------------------------------------------------------------------
|
|
439
|
+
// Streaming API call (OpenAI-compatible — works with Ollama)
|
|
440
|
+
// ---------------------------------------------------------------------------
|
|
441
|
+
|
|
442
|
+
async function streamApiCall(baseUrl, model, messages, tools, signal) {
|
|
443
|
+
const body = { model, messages, stream: true };
|
|
444
|
+
if (tools?.length) {
|
|
445
|
+
body.tools = tools;
|
|
446
|
+
body.tool_choice = 'auto';
|
|
447
|
+
}
|
|
448
|
+
return fetch(`${baseUrl}/v1/chat/completions`, {
|
|
449
|
+
method: 'POST',
|
|
450
|
+
headers: { 'Content-Type': 'application/json' },
|
|
451
|
+
body: JSON.stringify(body),
|
|
452
|
+
signal,
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
async function consumeStream(response, { onText, onAbortCheck }) {
|
|
457
|
+
const reader = response.body.getReader();
|
|
458
|
+
const decoder = new TextDecoder();
|
|
459
|
+
let buffer = '';
|
|
460
|
+
let content = '';
|
|
461
|
+
const toolCalls = {};
|
|
462
|
+
let finishReason = null;
|
|
463
|
+
let usage = null;
|
|
464
|
+
|
|
465
|
+
while (true) {
|
|
466
|
+
const { done, value } = await reader.read();
|
|
467
|
+
if (done) break;
|
|
468
|
+
if (onAbortCheck?.()) { reader.cancel(); break; }
|
|
469
|
+
|
|
470
|
+
buffer += decoder.decode(value, { stream: true });
|
|
471
|
+
const lines = buffer.split('\n');
|
|
472
|
+
buffer = lines.pop() || '';
|
|
473
|
+
|
|
474
|
+
for (const line of lines) {
|
|
475
|
+
const trimmed = line.trim();
|
|
476
|
+
if (!trimmed || !trimmed.startsWith('data: ')) continue;
|
|
477
|
+
const payload = trimmed.slice(6);
|
|
478
|
+
if (payload === '[DONE]') continue;
|
|
479
|
+
|
|
480
|
+
try {
|
|
481
|
+
const parsed = JSON.parse(payload);
|
|
482
|
+
if (parsed.usage) usage = parsed.usage;
|
|
483
|
+
const choice = parsed.choices?.[0];
|
|
484
|
+
if (!choice) continue;
|
|
485
|
+
if (choice.finish_reason) finishReason = choice.finish_reason;
|
|
486
|
+
|
|
487
|
+
const delta = choice.delta;
|
|
488
|
+
if (delta?.content) {
|
|
489
|
+
content += delta.content;
|
|
490
|
+
onText?.(delta.content);
|
|
491
|
+
}
|
|
492
|
+
if (delta?.tool_calls) {
|
|
493
|
+
for (const tc of delta.tool_calls) {
|
|
494
|
+
const idx = tc.index;
|
|
495
|
+
if (!toolCalls[idx]) toolCalls[idx] = { id: '', name: '', arguments: '' };
|
|
496
|
+
if (tc.id) toolCalls[idx].id = tc.id;
|
|
497
|
+
if (tc.function?.name) toolCalls[idx].name = tc.function.name;
|
|
498
|
+
if (tc.function?.arguments) toolCalls[idx].arguments += tc.function.arguments;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
} catch { /* skip malformed chunks */ }
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
return {
|
|
506
|
+
content,
|
|
507
|
+
toolCalls: Object.values(toolCalls).filter((tc) => tc.id && tc.name),
|
|
508
|
+
finishReason,
|
|
509
|
+
usage,
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// ---------------------------------------------------------------------------
|
|
514
|
+
// System prompt builder
|
|
515
|
+
// ---------------------------------------------------------------------------
|
|
516
|
+
|
|
517
|
+
async function buildSystemPrompt(workingDir) {
|
|
518
|
+
const parts = [
|
|
519
|
+
`You are a powerful agentic AI research assistant running on a local GPU. You operate in the project directory: ${workingDir}`,
|
|
520
|
+
'',
|
|
521
|
+
'You have access to tools for reading/writing files, running shell commands, and searching the codebase.',
|
|
522
|
+
'Use tools proactively to accomplish tasks. Always read files before editing them.',
|
|
523
|
+
'Be concise and focus on the task at hand.',
|
|
524
|
+
];
|
|
525
|
+
|
|
526
|
+
try {
|
|
527
|
+
const agentsPath = path.join(workingDir, 'AGENTS.md');
|
|
528
|
+
const agentsMd = await fs.readFile(agentsPath, 'utf-8');
|
|
529
|
+
if (agentsMd.trim()) {
|
|
530
|
+
parts.push('', '## Project Instructions (AGENTS.md)', agentsMd.trim());
|
|
531
|
+
}
|
|
532
|
+
} catch {}
|
|
533
|
+
|
|
534
|
+
return parts.join('\n');
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// ---------------------------------------------------------------------------
|
|
538
|
+
// Main entry point
|
|
539
|
+
// ---------------------------------------------------------------------------
|
|
540
|
+
|
|
541
|
+
export async function queryLocalGPU(command, options = {}, ws) {
|
|
542
|
+
const {
|
|
543
|
+
sessionId,
|
|
544
|
+
cwd,
|
|
545
|
+
projectPath,
|
|
546
|
+
model = 'qwen3-32b',
|
|
547
|
+
serverUrl,
|
|
548
|
+
gpuId,
|
|
549
|
+
env,
|
|
550
|
+
sessionMode,
|
|
551
|
+
stageTagKeys,
|
|
552
|
+
stageTagSource = 'task_context',
|
|
553
|
+
systemPrompt: customSystemPrompt,
|
|
554
|
+
permissionMode = 'bypassPermissions',
|
|
555
|
+
toolsSettings,
|
|
556
|
+
} = options;
|
|
557
|
+
|
|
558
|
+
const workingDirectory = cwd || projectPath || process.cwd();
|
|
559
|
+
const ollamaUrl = getOllamaUrl(options);
|
|
560
|
+
|
|
561
|
+
// Verify Ollama is reachable
|
|
562
|
+
try {
|
|
563
|
+
const check = await fetch(`${ollamaUrl}/api/tags`, { signal: AbortSignal.timeout(5000) });
|
|
564
|
+
if (!check.ok) throw new Error(`Ollama returned ${check.status}`);
|
|
565
|
+
} catch (err) {
|
|
566
|
+
sendMessage(ws, {
|
|
567
|
+
type: 'localgpu-error',
|
|
568
|
+
error: `Cannot reach Ollama at ${ollamaUrl}. Make sure Ollama is running (ollama serve). Error: ${err.message}`,
|
|
569
|
+
errorType: 'auth',
|
|
570
|
+
isRetryable: false,
|
|
571
|
+
sessionId,
|
|
572
|
+
});
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const currentSessionId = sessionId || `localgpu-${crypto.randomUUID()}`;
|
|
577
|
+
const abortController = new AbortController();
|
|
578
|
+
const allowedTools = [...(toolsSettings?.allowedTools || [])];
|
|
579
|
+
const disallowedTools = [...(toolsSettings?.disallowedTools || [])];
|
|
580
|
+
|
|
581
|
+
try {
|
|
582
|
+
if (workingDirectory) {
|
|
583
|
+
try {
|
|
584
|
+
await ensureProjectSkillLinks(workingDirectory);
|
|
585
|
+
await writeProjectTemplates(workingDirectory);
|
|
586
|
+
} catch (err) {
|
|
587
|
+
console.warn('[LocalGPU] Project template init warning:', err.message);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
if (sessionId && workingDirectory) {
|
|
592
|
+
applyStageTagsToSession({ sessionId, projectPath: workingDirectory, stageTagKeys, source: stageTagSource });
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
activeLocalGPUSessions.set(currentSessionId, {
|
|
596
|
+
status: 'running',
|
|
597
|
+
abortController,
|
|
598
|
+
startTime: Date.now(),
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
const userText = (command || '').replace(/\s*\[Context:[^\]]*\]\s*/gi, '').trim();
|
|
602
|
+
const sessionDisplayName = userText.slice(0, 100) || null;
|
|
603
|
+
|
|
604
|
+
if (workingDirectory) {
|
|
605
|
+
recordIndexedSession({
|
|
606
|
+
sessionId: currentSessionId,
|
|
607
|
+
provider: 'local',
|
|
608
|
+
projectPath: workingDirectory,
|
|
609
|
+
sessionMode: sessionMode || 'research',
|
|
610
|
+
displayName: sessionDisplayName,
|
|
611
|
+
stageTagKeys,
|
|
612
|
+
tagSource: stageTagSource,
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
sendMessage(ws, {
|
|
617
|
+
type: 'session-created',
|
|
618
|
+
sessionId: currentSessionId,
|
|
619
|
+
provider: 'local',
|
|
620
|
+
mode: sessionMode || 'research',
|
|
621
|
+
startTime: activeLocalGPUSessions.get(currentSessionId).startTime,
|
|
622
|
+
displayName: sessionDisplayName || 'Local GPU Session',
|
|
623
|
+
projectName: workingDirectory ? encodeProjectPath(workingDirectory) : undefined,
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
const systemContent = customSystemPrompt || await buildSystemPrompt(workingDirectory);
|
|
627
|
+
const messages = [{ role: 'system', content: systemContent }];
|
|
628
|
+
|
|
629
|
+
if (sessionId) {
|
|
630
|
+
const history = await loadHistory(sessionId);
|
|
631
|
+
if (history.length) {
|
|
632
|
+
console.log(`[LocalGPU] Resumed session ${sessionId} with ${history.length} history messages`);
|
|
633
|
+
messages.push(...history);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
messages.push({ role: 'user', content: command });
|
|
638
|
+
await appendSession(currentSessionId, { role: 'user', content: command }).catch(() => {});
|
|
639
|
+
|
|
640
|
+
const tools = permissionMode === 'plan'
|
|
641
|
+
? TOOL_SCHEMAS.filter((t) => READ_ONLY_TOOLS.has(t.function.name))
|
|
642
|
+
: TOOL_SCHEMAS;
|
|
643
|
+
|
|
644
|
+
// Agent loop
|
|
645
|
+
let turn = 0;
|
|
646
|
+
let noToolFallback = false;
|
|
647
|
+
let cumulativeTokens = 0;
|
|
648
|
+
const contextWindow = 32000;
|
|
649
|
+
|
|
650
|
+
while (turn < MAX_AGENT_TURNS) {
|
|
651
|
+
turn++;
|
|
652
|
+
const session = activeLocalGPUSessions.get(currentSessionId);
|
|
653
|
+
if (!session || session.status === 'aborted') break;
|
|
654
|
+
|
|
655
|
+
console.log(`[LocalGPU] Turn ${turn}/${MAX_AGENT_TURNS} · model=${model} · msgs=${messages.length} · server=${ollamaUrl}`);
|
|
656
|
+
|
|
657
|
+
const response = await streamApiCall(
|
|
658
|
+
ollamaUrl, model, messages,
|
|
659
|
+
noToolFallback ? [] : tools,
|
|
660
|
+
abortController.signal,
|
|
661
|
+
);
|
|
662
|
+
|
|
663
|
+
if (!response.ok) {
|
|
664
|
+
const body = await response.text();
|
|
665
|
+
let msg = `Local GPU API error (${response.status})`;
|
|
666
|
+
try { msg = JSON.parse(body).error?.message || JSON.parse(body).error || msg; } catch {}
|
|
667
|
+
|
|
668
|
+
if (!noToolFallback && response.status === 400 && /tool|function/i.test(msg)) {
|
|
669
|
+
console.log('[LocalGPU] Model may not support tools — retrying without tools');
|
|
670
|
+
noToolFallback = true;
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
console.error(`[LocalGPU] API error: ${msg}`);
|
|
675
|
+
sendMessage(ws, {
|
|
676
|
+
type: 'localgpu-error',
|
|
677
|
+
error: msg,
|
|
678
|
+
errorType: 'api',
|
|
679
|
+
isRetryable: response.status >= 500,
|
|
680
|
+
sessionId: currentSessionId,
|
|
681
|
+
});
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const result = await consumeStream(response, {
|
|
686
|
+
onText(delta) {
|
|
687
|
+
sendMessage(ws, {
|
|
688
|
+
type: 'localgpu-response',
|
|
689
|
+
sessionId: currentSessionId,
|
|
690
|
+
data: {
|
|
691
|
+
type: 'assistant_message',
|
|
692
|
+
message: { role: 'assistant', content: delta },
|
|
693
|
+
startTime: activeLocalGPUSessions.get(currentSessionId)?.startTime,
|
|
694
|
+
},
|
|
695
|
+
});
|
|
696
|
+
},
|
|
697
|
+
onAbortCheck() {
|
|
698
|
+
const s = activeLocalGPUSessions.get(currentSessionId);
|
|
699
|
+
return !s || s.status === 'aborted';
|
|
700
|
+
},
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
if (result.usage) {
|
|
704
|
+
cumulativeTokens = (result.usage.prompt_tokens || 0) + (result.usage.completion_tokens || 0);
|
|
705
|
+
} else {
|
|
706
|
+
cumulativeTokens += (result.content?.length || 0) / 4;
|
|
707
|
+
for (const tc of result.toolCalls) {
|
|
708
|
+
cumulativeTokens += (tc.arguments?.length || 0) / 4;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
sendMessage(ws, {
|
|
712
|
+
type: 'token-budget',
|
|
713
|
+
data: { used: Math.round(cumulativeTokens), total: contextWindow },
|
|
714
|
+
sessionId: currentSessionId,
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
if (result.toolCalls.length === 0) {
|
|
718
|
+
if (result.content) {
|
|
719
|
+
await appendSession(currentSessionId, { role: 'assistant', content: result.content }).catch(() => {});
|
|
720
|
+
}
|
|
721
|
+
break;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
console.log(`[LocalGPU] ${result.toolCalls.length} tool call(s) in turn ${turn}`);
|
|
725
|
+
|
|
726
|
+
const assistantMsg = {
|
|
727
|
+
role: 'assistant',
|
|
728
|
+
content: result.content || null,
|
|
729
|
+
tool_calls: result.toolCalls.map((tc) => ({
|
|
730
|
+
id: tc.id,
|
|
731
|
+
type: 'function',
|
|
732
|
+
function: { name: tc.name, arguments: tc.arguments },
|
|
733
|
+
})),
|
|
734
|
+
};
|
|
735
|
+
messages.push(assistantMsg);
|
|
736
|
+
await appendSession(currentSessionId, assistantMsg).catch(() => {});
|
|
737
|
+
|
|
738
|
+
const structuredToolUseContent = result.toolCalls.map((tc) => {
|
|
739
|
+
let input;
|
|
740
|
+
try { input = JSON.parse(tc.arguments); } catch { input = {}; }
|
|
741
|
+
return { type: 'tool_use', id: tc.id, name: tc.name, input };
|
|
742
|
+
});
|
|
743
|
+
sendMessage(ws, {
|
|
744
|
+
type: 'localgpu-response',
|
|
745
|
+
sessionId: currentSessionId,
|
|
746
|
+
data: {
|
|
747
|
+
type: 'structured_turn',
|
|
748
|
+
message: { role: 'assistant', content: structuredToolUseContent },
|
|
749
|
+
},
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
for (const tc of result.toolCalls) {
|
|
753
|
+
let args;
|
|
754
|
+
try { args = JSON.parse(tc.arguments); } catch { args = {}; }
|
|
755
|
+
|
|
756
|
+
console.log(`[LocalGPU] → ${tc.name}(${JSON.stringify(args).slice(0, 120)})`);
|
|
757
|
+
|
|
758
|
+
const allowed = await checkToolPermission(
|
|
759
|
+
tc.name, args, permissionMode, allowedTools, disallowedTools, ws, currentSessionId,
|
|
760
|
+
toolsSettings?.skipPermissions === true,
|
|
761
|
+
);
|
|
762
|
+
|
|
763
|
+
const output = allowed
|
|
764
|
+
? await executeTool(tc.name, args, workingDirectory)
|
|
765
|
+
: `Permission denied for tool: ${tc.name}`;
|
|
766
|
+
|
|
767
|
+
sendMessage(ws, {
|
|
768
|
+
type: 'localgpu-response',
|
|
769
|
+
sessionId: currentSessionId,
|
|
770
|
+
data: {
|
|
771
|
+
type: 'structured_result',
|
|
772
|
+
message: {
|
|
773
|
+
role: 'user',
|
|
774
|
+
content: [{
|
|
775
|
+
type: 'tool_result',
|
|
776
|
+
tool_use_id: tc.id,
|
|
777
|
+
content: output.slice(0, 2000),
|
|
778
|
+
is_error: output.startsWith('Error'),
|
|
779
|
+
}],
|
|
780
|
+
},
|
|
781
|
+
},
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
const toolMsg = { role: 'tool', tool_call_id: tc.id, content: output };
|
|
785
|
+
messages.push(toolMsg);
|
|
786
|
+
await appendSession(currentSessionId, toolMsg).catch(() => {});
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
if (turn >= MAX_AGENT_TURNS) {
|
|
791
|
+
console.warn(`[LocalGPU] Reached max agent turns (${MAX_AGENT_TURNS})`);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
sendMessage(ws, { type: 'localgpu-complete', sessionId: currentSessionId, exitCode: 0 });
|
|
795
|
+
} catch (error) {
|
|
796
|
+
console.error('[LocalGPU] Error:', error.name, error.message);
|
|
797
|
+
if (error.name === 'AbortError') {
|
|
798
|
+
sendMessage(ws, { type: 'localgpu-complete', sessionId: currentSessionId, exitCode: 1, aborted: true });
|
|
799
|
+
} else {
|
|
800
|
+
const { errorType, isRetryable } = classifyError(error.message);
|
|
801
|
+
sendMessage(ws, {
|
|
802
|
+
type: 'localgpu-error',
|
|
803
|
+
error: error.message,
|
|
804
|
+
errorType,
|
|
805
|
+
isRetryable,
|
|
806
|
+
sessionId: currentSessionId,
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
} finally {
|
|
810
|
+
const session = activeLocalGPUSessions.get(currentSessionId);
|
|
811
|
+
if (session) session.status = 'completed';
|
|
812
|
+
|
|
813
|
+
if (workingDirectory) {
|
|
814
|
+
try {
|
|
815
|
+
await reconcileLocalGPUSessionIndex(workingDirectory, {
|
|
816
|
+
sessionId: currentSessionId,
|
|
817
|
+
projectName: encodeProjectPath(workingDirectory),
|
|
818
|
+
});
|
|
819
|
+
} catch (err) {
|
|
820
|
+
console.warn('[LocalGPU] Failed to reconcile session index:', err.message);
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// ---------------------------------------------------------------------------
|
|
827
|
+
// Session management exports
|
|
828
|
+
// ---------------------------------------------------------------------------
|
|
829
|
+
|
|
830
|
+
export function abortLocalGPUSession(sessionId) {
|
|
831
|
+
const session = activeLocalGPUSessions.get(sessionId);
|
|
832
|
+
if (!session) return false;
|
|
833
|
+
session.status = 'aborted';
|
|
834
|
+
session.abortController?.abort();
|
|
835
|
+
activeLocalGPUSessions.delete(sessionId);
|
|
836
|
+
return true;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
export function isLocalGPUSessionActive(sessionId) {
|
|
840
|
+
return activeLocalGPUSessions.get(sessionId)?.status === 'running';
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
export function getLocalGPUSessionStartTime(sessionId) {
|
|
844
|
+
return activeLocalGPUSessions.get(sessionId)?.startTime || null;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
export function getActiveLocalGPUSessions() {
|
|
848
|
+
return Array.from(activeLocalGPUSessions.entries())
|
|
849
|
+
.filter(([, s]) => s.status === 'running')
|
|
850
|
+
.map(([id, s]) => ({ sessionId: id, startTime: s.startTime }));
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
setInterval(() => {
|
|
854
|
+
const now = Date.now();
|
|
855
|
+
for (const [id, session] of activeLocalGPUSessions.entries()) {
|
|
856
|
+
if (session.status !== 'running' && now - (session.startTime || 0) > 30 * 60 * 1000) {
|
|
857
|
+
activeLocalGPUSessions.delete(id);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
}, 5 * 60 * 1000);
|