File size: 15,540 Bytes
4692a30
 
 
 
765fe0a
 
 
 
4692a30
 
 
 
 
 
 
765fe0a
4692a30
765fe0a
 
 
 
 
 
 
 
 
 
 
 
 
4692a30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
765fe0a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4692a30
 
765fe0a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4692a30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
765fe0a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1a925d4
 
765fe0a
 
 
 
 
 
1f82101
765fe0a
1f82101
 
 
 
 
 
 
 
765fe0a
 
 
1f82101
 
 
765fe0a
 
 
 
 
 
 
 
 
1f82101
765fe0a
 
 
 
 
 
1f82101
765fe0a
1f82101
 
 
 
 
 
 
 
 
 
765fe0a
1f82101
765fe0a
 
 
 
 
 
 
 
 
 
 
 
1f82101
765fe0a
 
 
 
 
1f82101
 
765fe0a
 
 
 
 
 
 
 
 
1f82101
765fe0a
 
 
4692a30
 
765fe0a
 
4692a30
 
 
 
 
 
 
765fe0a
 
4692a30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
765fe0a
4692a30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
#!/usr/bin/env node
// Agent Web's own MCP server (stdio, newline-delimited JSON-RPC 2.0).
//  - share_file: hands files to the user; the web server sees the result and
//    shows them in the chat as download cards (see server/chat.js).
//  - note_file: a short note about a file for the project map
//    (.agentweb/notes.json, see server/memory.js), so it is not re-read later.
//  - ask_gemini: delegates research / big-file / image analysis to the Gemini
//    CLI (listed only when it is installed and signed in).
// No dependencies on purpose: it starts fast and needs nothing from npm.
'use strict';

const fs = require('fs');
const path = require('path');
const os = require('os');
const readline = require('readline');
const { execFile } = require('child_process');

let memory = null;
let redact = null;
try {
  memory = require(path.join(__dirname, '..', 'server', 'memory.js'));
  redact = require(path.join(__dirname, '..', 'server', 'redact.js'));
} catch {
  memory = null;
}

const SERVER_INFO = { name: 'agentweb', version: '2.0.0' };
const GEMINI_TIMEOUT_MS = 6 * 60 * 1000;
const MAX_GEMINI_ANSWER = 60000;
const MAX_GEMINI_FILES = 20;
const MAX_SHARE_FILES = 10;
const MAX_SHARE_BYTES = 50 * 1024 * 1024;
const MAX_SHARE_TOTAL = 100 * 1024 * 1024;
const SYSTEM_PATH_RE = /^\/(proc|sys|dev)(\/|$)/;

const TOOLS = [
  {
    name: 'share_file',
    title: 'Dosya paylaş',
    description:
      'Send files from this machine to the user: they appear in the Agent Web chat as download cards ' +
      '(images also get a preview). Use it whenever the user asks you to send, share or give them a file, ' +
      'or when a result is better delivered as a file than as pasted text (long code, logs, CSV, documents, ' +
      'archives, images). Write the file first, then call this with its path. Folders cannot be shared; ' +
      'archive them first (zip/tar). At most 10 files, 50 MB each.',
    inputSchema: {
      type: 'object',
      properties: {
        paths: {
          type: 'array',
          items: { type: 'string' },
          minItems: 1,
          maxItems: MAX_SHARE_FILES,
          description: 'File paths, absolute or relative to the current working directory.',
        },
      },
      required: ['paths'],
      additionalProperties: false,
    },
    annotations: { title: 'Dosya paylaş', readOnlyHint: true, openWorldHint: false },
  },
  {
    name: 'note_file',
    title: 'Dosya notu',
    description:
      'Save a 1-3 line note about what a file contains (its purpose, main parts, where the important logic is). ' +
      'The note appears next to the file in .agentweb/MAP.md, so later conversations know the file without reading it again. ' +
      'Call it after reading and understanding a large or central file. An empty note deletes it.',
    inputSchema: {
      type: 'object',
      properties: {
        path: { type: 'string', description: 'File path, relative to the working directory (or absolute inside it).' },
        note: { type: 'string', description: 'The note, at most ~300 characters.' },
      },
      required: ['path', 'note'],
      additionalProperties: false,
    },
    annotations: { title: 'Dosya notu', readOnlyHint: false, destructiveHint: false, openWorldHint: false },
  },
];

const GEMINI_TOOL = {
  name: 'ask_gemini',
  title: "Gemini'ye sor",
  description:
    "Ask Google Gemini (via the Gemini CLI, read-only mode) and get its answer back. It has a 1M-token context, Google Search, and reads images, PDFs, audio and video. " +
    'Use it to save the user\'s Claude usage on: web research (current facts, docs, comparisons), reading/summarising very large files or logs, a first overview of a big codebase, ' +
    'and analysing images/screenshots/PDFs/media. Pass the relevant files in `files` (paths relative to the working directory). ' +
    'Ask a precise question and say what form of answer you need; verify important claims and never include secrets.',
  inputSchema: {
    type: 'object',
    properties: {
      prompt: { type: 'string', description: 'The question or task for Gemini, with all needed context.' },
      files: { type: 'array', items: { type: 'string' }, maxItems: MAX_GEMINI_FILES, description: 'Files (or folders) Gemini should read: code, logs, images, PDFs, audio, video.' },
      model: { type: 'string', description: 'Optional Gemini model id (default: the CLI default).' },
    },
    required: ['prompt'],
    additionalProperties: false,
  },
  annotations: { title: "Gemini'ye sor", readOnlyHint: true, openWorldHint: true },
};

class ToolError extends Error {}

function formatBytes(n) {
  if (n < 1024) return `${n} B`;
  if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
  return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}

function text(value) {
  return { type: 'text', text: value };
}

function shareFile(args) {
  let raw = args.paths;
  if (typeof raw === 'string') raw = [raw];
  if (!Array.isArray(raw) || !raw.length) throw new ToolError('paths en az bir dosya yolu içermeli.');
  if (raw.length > MAX_SHARE_FILES) throw new ToolError(`Tek seferde en fazla ${MAX_SHARE_FILES} dosya paylaşılabilir.`);
  const files = [];
  let total = 0;
  for (const value of raw) {
    if (typeof value !== 'string' || !value.trim() || value.includes('\0')) throw new ToolError('Geçersiz dosya yolu.');
    const expanded = value.startsWith('~/') ? path.join(os.homedir(), value.slice(2)) : value;
    const abs = path.resolve(process.cwd(), expanded);
    let stat;
    try {
      stat = fs.statSync(abs);
    } catch {
      throw new ToolError(`Dosya bulunamadı: ${value}`);
    }
    if (stat.isDirectory()) throw new ToolError(`${value} bir klasör; önce zip ya da tar ile arşivleyip arşivi paylaşın.`);
    if (!stat.isFile()) throw new ToolError(`${value} normal bir dosya değil.`);
    // /proc and friends look like regular files but hold live process state
    // (environment variables, memory maps) that should never leave the box.
    if (SYSTEM_PATH_RE.test(fs.realpathSync(abs))) throw new ToolError(`${value} bir sistem dosyası; paylaşılamaz.`);
    if (stat.size > MAX_SHARE_BYTES) throw new ToolError(`${value} çok büyük (${formatBytes(stat.size)}; en fazla 50 MB).`);
    try {
      fs.accessSync(abs, fs.constants.R_OK);
    } catch {
      throw new ToolError(`${value} okunamıyor (izin yok).`);
    }
    total += stat.size;
    if (total > MAX_SHARE_TOTAL) throw new ToolError('Dosyaların toplamı 100 MB\'ı aşıyor; daha az dosya paylaşın.');
    files.push({ path: fs.realpathSync(abs), name: path.basename(abs), size: stat.size });
  }
  const summary = files.map((f) => `${f.name} (${formatBytes(f.size)})`).join(', ');
  return [
    text(`Paylaşıldı: ${summary}. Agent Web sohbetinde indirme kartı olarak görünür.`),
    text(JSON.stringify({ agentwebShare: files })),
  ];
}

function noteFile(args) {
  if (!memory) throw new ToolError('Not kaydedilemedi: Agent Web modülü bulunamadı.');
  if (typeof args.path !== 'string' || !args.path.trim()) throw new ToolError('path gerekli.');
  if (typeof args.note !== 'string') throw new ToolError('note gerekli.');
  let key;
  try {
    key = memory.saveNote(process.cwd(), args.path.trim(), args.note);
  } catch (err) {
    throw new ToolError(err.message);
  }
  return [text(args.note.trim() ? `Not kaydedildi: ${key}` : `Not silindi: ${key}`)];
}

function onPath(name) {
  for (const dir of String(process.env.PATH || '').split(':')) {
    if (!dir) continue;
    const candidate = path.join(dir, name);
    try {
      fs.accessSync(candidate, fs.constants.X_OK);
      return candidate;
    } catch {
      // yok
    }
  }
  return null;
}

function geminiReady() {
  // Set for Gemini's own copy of this server (see setup-mcp.js): Gemini need not ask itself.
  if (process.env.AGENTWEB_MCP_NO_GEMINI === '1') return false;
  try {
    const prefs = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.cc-web', 'prefs.json'), 'utf8'));
    if (prefs && prefs.gemini === false) return false;
  } catch {
    // varsayilan: acik
  }
  if (agyBin()) return true;
  if (!onPath('gemini')) return false;
  return !!(process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY);
}

// Antigravity CLI, once signed in (Google account / AI Pro subscription).
// Gemini CLI only works with an API key since Google moved personal accounts.
function agyBin() {
  const bin = onPath('agy');
  return bin && fs.existsSync(path.join(os.homedir(), '.gemini', 'antigravity-cli', 'antigravity-oauth-token')) ? bin : null;
}

function askGemini(args) {
  const agy = agyBin();
  const bin = agy || onPath('gemini');
  if (!bin) throw new ToolError('Gemini için ne Antigravity CLI (agy) ne Gemini CLI kurulu.');
  let prompt = typeof args.prompt === 'string' ? args.prompt.trim() : '';
  if (!prompt) throw new ToolError('prompt gerekli.');
  if (redact) prompt = redact.redactText(prompt);
  const files = Array.isArray(args.files) ? args.files.filter((f) => typeof f === 'string' && f.trim()).slice(0, MAX_GEMINI_FILES) : [];
  const refs = [];
  for (const f of files) {
    const abs = path.resolve(process.cwd(), f.startsWith('~/') ? path.join(os.homedir(), f.slice(2)) : f);
    if (!fs.existsSync(abs)) throw new ToolError(`Dosya bulunamadı: ${f}`);
    if (SYSTEM_PATH_RE.test(fs.realpathSync(abs))) throw new ToolError(`${f} bir sistem dosyası.`);
    refs.push(agy ? abs : `@${abs.replace(/ /g, '\\ ')}`);
  }
  const full = [
    'You are assisting another AI agent (Claude) that is coding for a user. Answer precisely and concisely (normally under ~700 words unless the task needs more).',
    'For web research, cite the source URLs. Say clearly when you are unsure. Do not modify anything.',
    '',
    prompt,
    refs.length ? `\nFiles: ${refs.join(agy ? ', ' : ' ')}` : '',
  ].join('\n');
  const model = typeof args.model === 'string' && /^[A-Za-z0-9._-]{1,64}$/.test(args.model) ? args.model : '';
  let argv;
  if (agy) {
    argv = [`--print=${full}`, '--mode', 'plan', '--output-format', 'json'];
    // agy model names carry the effort (e.g. gemini-3.8-flash-low).
    if (/-(low|medium|high)$/.test(model)) argv.push('--model', model);
  } else {
    argv = ['-p', full, '--approval-mode', 'plan', '--output-format', 'json', '--skip-trust'];
    if (model) argv.push('-m', model);
  }
  return new Promise((resolve, reject) => {
    const child = execFile(bin, argv, { cwd: process.cwd(), env: process.env, timeout: GEMINI_TIMEOUT_MS, maxBuffer: 20 * 1024 * 1024 }, (err, stdout, stderr) => {
      let parsed = null;
      // The JSON answer, without any log lines printed around it.
      const raw = String(stdout || '');
      const from = raw.search(/^\{/m);
      const to = raw.lastIndexOf('}');
      try {
        parsed = from >= 0 && to > from ? JSON.parse(raw.slice(from, to + 1)) : null;
      } catch {
        parsed = null;
      }
      if (parsed && parsed.error) {
        const msg = parsed.error.message || String(parsed.error);
        reject(new ToolError(/auth method|login|credentials|authentication|sign in|Eligibility/i.test(msg) ? `Gemini'ye giriş yapılmamış: Agent Web Terminal sekmesinde \`agy\` çalıştırıp Google ile giriş yap (ya da GEMINI_API_KEY secret'ı ekle). Ayrıntı: ${msg}` : `Gemini hatası: ${msg}`));
        return;
      }
      if (parsed && typeof parsed.response === 'string') {
        let answer = parsed.response.trim();
        if (answer.length > MAX_GEMINI_ANSWER) answer = `${answer.slice(0, MAX_GEMINI_ANSWER)}\n… (yanıt kırpıldı)`;
        let usage = parsed.stats && parsed.stats.models ? Object.entries(parsed.stats.models).map(([m, s]) => `${m}: ${(s.tokens && s.tokens.total) || '?'} token`).join(', ') : '';
        if (!usage && parsed.usage && parsed.usage.total_tokens) usage = `${parsed.usage.total_tokens} token`;
        resolve([text(answer || '(Gemini boş yanıt verdi)'), ...(usage ? [text(`[Gemini kullanımı: ${usage}]`)] : [])]);
        return;
      }
      if (err) {
        reject(new ToolError(err.killed ? 'Gemini zaman aşımına uğradı (6 dk).' : `Gemini çalışmadı: ${String(stderr || err.message).trim().split('\n').slice(-3).join(' ')}`));
        return;
      }
      resolve([text(String(stdout || '').trim().slice(0, MAX_GEMINI_ANSWER) || '(boş yanıt)')]);
    });
    if (child.stdin) child.stdin.end();
  });
}

const HANDLERS = {
  share_file: shareFile,
  note_file: noteFile,
  ask_gemini: askGemini,
};

function send(message) {
  process.stdout.write(`${JSON.stringify(message)}\n`);
}

function listedTools() {
  const tools = memory ? TOOLS : TOOLS.filter((t) => t.name !== 'note_file');
  return geminiReady() ? [...tools, GEMINI_TOOL] : tools;
}

async function handle(message) {
  const { id, method, params } = message;
  const isRequest = id !== undefined && id !== null;
  if (!method) return;
  if (!isRequest) return; // notifications (initialized, cancelled, ...)
  try {
    switch (method) {
      case 'initialize': {
        const requested = params && params.protocolVersion;
        send({
          jsonrpc: '2.0',
          id,
          result: {
            protocolVersion: /^\d{4}-\d{2}-\d{2}$/.test(requested || '') ? requested : '2025-06-18',
            capabilities: { tools: { listChanged: false } },
            serverInfo: SERVER_INFO,
            instructions: 'Agent Web tools. share_file sends files to the user in the chat; note_file records what a file contains for the project map; ask_gemini (when listed) delegates research and media/large-file analysis to Gemini.',
          },
        });
        return;
      }
      case 'ping':
        send({ jsonrpc: '2.0', id, result: {} });
        return;
      case 'tools/list':
        send({ jsonrpc: '2.0', id, result: { tools: listedTools() } });
        return;
      case 'tools/call': {
        const name = params && params.name;
        const listed = listedTools().some((tool) => tool.name === name);
        const handler = listed && Object.prototype.hasOwnProperty.call(HANDLERS, name) ? HANDLERS[name] : null;
        if (!handler) {
          send({ jsonrpc: '2.0', id, error: { code: -32602, message: `Unknown tool: ${name}` } });
          return;
        }
        try {
          const content = await handler((params && params.arguments) || {});
          send({ jsonrpc: '2.0', id, result: { content } });
        } catch (err) {
          const messageText = err instanceof ToolError ? err.message : `Hata: ${err && err.message ? err.message : String(err)}`;
          send({ jsonrpc: '2.0', id, result: { content: [text(messageText)], isError: true } });
        }
        return;
      }
      default:
        send({ jsonrpc: '2.0', id, error: { code: -32601, message: `Method not found: ${method}` } });
    }
  } catch (err) {
    send({ jsonrpc: '2.0', id, error: { code: -32603, message: err && err.message ? err.message : String(err) } });
  }
}

if (require.main === module) {
  const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
  rl.on('line', (line) => {
    if (!line.trim()) return;
    let message;
    try {
      message = JSON.parse(line);
    } catch {
      send({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } });
      return;
    }
    for (const item of Array.isArray(message) ? message : [message]) {
      if (item && typeof item === 'object') handle(item);
    }
  });
  rl.on('close', () => process.exit(0));
}

module.exports = { TOOLS, HANDLERS };