pikiclaw 0.2.35

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.
@@ -0,0 +1,230 @@
1
+ /**
2
+ * driver-gemini.ts — Gemini CLI agent driver.
3
+ *
4
+ * Requires `gemini` CLI installed (https://github.com/google-gemini/gemini-cli).
5
+ * Stream protocol: spawns `gemini` with JSON output and parses stdout line-by-line.
6
+ */
7
+ import { registerDriver } from './agent-driver.js';
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { run, agentLog, detectAgentBin, pushRecentActivity, listPikiclawSessions, isPendingSessionId, emptyUsage, } from './code-agent.js';
11
+ // ---------------------------------------------------------------------------
12
+ // Command & parser
13
+ // ---------------------------------------------------------------------------
14
+ function geminiCmd(o) {
15
+ const args = ['gemini', '--output-format', 'stream-json'];
16
+ if (o.geminiModel)
17
+ args.push('--model', o.geminiModel);
18
+ if (o.sessionId)
19
+ args.push('--resume', o.sessionId);
20
+ if (o.mcpConfigPath)
21
+ args.push('--mcp-config', o.mcpConfigPath);
22
+ if (o.geminiExtraArgs?.length)
23
+ args.push(...o.geminiExtraArgs);
24
+ // gemini's -p requires the prompt as its value (not via stdin)
25
+ args.push('-p', o.prompt);
26
+ return args;
27
+ }
28
+ function geminiParse(ev, s) {
29
+ const t = ev.type || '';
30
+ // init event: {"type":"init","session_id":"...","model":"..."}
31
+ if (t === 'init') {
32
+ s.sessionId = ev.session_id ?? s.sessionId;
33
+ s.model = ev.model ?? s.model;
34
+ }
35
+ // message delta: {"type":"message","role":"assistant","content":"...","delta":true}
36
+ if (t === 'message' && ev.role === 'assistant' && ev.delta) {
37
+ s.text += ev.content || '';
38
+ }
39
+ // tool_call event (if gemini uses tools)
40
+ if (t === 'tool_call') {
41
+ const name = ev.name || ev.tool || 'tool';
42
+ pushRecentActivity(s.recentActivity, `Using ${name}...`);
43
+ s.activity = s.recentActivity.join('\n');
44
+ }
45
+ // tool_result event
46
+ if (t === 'tool_result') {
47
+ const name = ev.name || ev.tool || 'tool';
48
+ pushRecentActivity(s.recentActivity, `${name} done`);
49
+ s.activity = s.recentActivity.join('\n');
50
+ }
51
+ // result event: {"type":"result","status":"success","stats":{...}}
52
+ if (t === 'result') {
53
+ s.sessionId = ev.session_id ?? s.sessionId;
54
+ if (ev.status === 'error' || ev.status === 'failure') {
55
+ s.errors = [ev.error || ev.message || `Gemini returned status: ${ev.status}`];
56
+ }
57
+ s.stopReason = ev.status === 'success' ? 'end_turn' : ev.status;
58
+ const u = ev.stats;
59
+ if (u) {
60
+ s.inputTokens = u.input_tokens ?? u.input ?? s.inputTokens;
61
+ s.outputTokens = u.output_tokens ?? u.output ?? s.outputTokens;
62
+ s.cachedInputTokens = u.cached ?? s.cachedInputTokens;
63
+ }
64
+ }
65
+ }
66
+ // ---------------------------------------------------------------------------
67
+ // Stream
68
+ // ---------------------------------------------------------------------------
69
+ export async function doGeminiStream(opts) {
70
+ // Prompt is passed as -p argument; send empty stdin so run() doesn't duplicate it
71
+ const streamOpts = { ...opts, _stdinOverride: '' };
72
+ return run(geminiCmd(opts), streamOpts, geminiParse);
73
+ }
74
+ // ---------------------------------------------------------------------------
75
+ // Sessions / Tail
76
+ // ---------------------------------------------------------------------------
77
+ /** Resolve Gemini project name for a workdir from ~/.gemini/projects.json */
78
+ function geminiProjectName(workdir) {
79
+ const home = process.env.HOME || '';
80
+ if (!home)
81
+ return null;
82
+ const projectsPath = path.join(home, '.gemini', 'projects.json');
83
+ try {
84
+ const data = JSON.parse(fs.readFileSync(projectsPath, 'utf8'));
85
+ const projects = data?.projects;
86
+ if (!projects || typeof projects !== 'object')
87
+ return null;
88
+ const resolved = path.resolve(workdir);
89
+ // Exact match first, then check entries
90
+ if (projects[resolved])
91
+ return projects[resolved];
92
+ for (const [dir, name] of Object.entries(projects)) {
93
+ if (path.resolve(dir) === resolved)
94
+ return name;
95
+ }
96
+ }
97
+ catch { /* skip */ }
98
+ return null;
99
+ }
100
+ /** Read native Gemini CLI sessions from ~/.gemini/tmp/{projectName}/chats/ */
101
+ function getNativeGeminiSessions(workdir) {
102
+ const home = process.env.HOME || '';
103
+ if (!home)
104
+ return [];
105
+ const projectName = geminiProjectName(workdir);
106
+ if (!projectName)
107
+ return [];
108
+ const chatsDir = path.join(home, '.gemini', 'tmp', projectName, 'chats');
109
+ if (!fs.existsSync(chatsDir))
110
+ return [];
111
+ const sessions = [];
112
+ let entries;
113
+ try {
114
+ entries = fs.readdirSync(chatsDir, { withFileTypes: true });
115
+ }
116
+ catch {
117
+ return [];
118
+ }
119
+ for (const entry of entries) {
120
+ if (!entry.isFile() || !entry.name.startsWith('session-') || !entry.name.endsWith('.json'))
121
+ continue;
122
+ const filePath = path.join(chatsDir, entry.name);
123
+ try {
124
+ const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
125
+ if (!data.sessionId)
126
+ continue;
127
+ // Extract title from first user message
128
+ let title = null;
129
+ const messages = Array.isArray(data.messages) ? data.messages : [];
130
+ for (const msg of messages) {
131
+ if (msg.type === 'user') {
132
+ const content = Array.isArray(msg.content) ? msg.content : [];
133
+ const text = content.map((c) => c?.text || '').join(' ').replace(/\s+/g, ' ').trim();
134
+ if (text) {
135
+ title = text.length <= 120 ? text : `${text.slice(0, 117).trimEnd()}...`;
136
+ }
137
+ break;
138
+ }
139
+ }
140
+ sessions.push({
141
+ sessionId: data.sessionId,
142
+ agent: 'gemini',
143
+ workdir,
144
+ workspacePath: null,
145
+ model: null,
146
+ createdAt: data.startTime || null,
147
+ title,
148
+ running: data.lastUpdated ? Date.now() - Date.parse(data.lastUpdated) < 10_000 : false,
149
+ });
150
+ }
151
+ catch { /* skip */ }
152
+ }
153
+ return sessions;
154
+ }
155
+ function getGeminiSessions(workdir, limit) {
156
+ const resolvedWorkdir = path.resolve(workdir);
157
+ // Merge pikiclaw-tracked sessions with native Gemini sessions
158
+ const pikiclawSessions = listPikiclawSessions(resolvedWorkdir, 'gemini').map(record => ({
159
+ sessionId: record.sessionId,
160
+ agent: 'gemini',
161
+ workdir: record.workdir,
162
+ workspacePath: record.workspacePath,
163
+ model: record.model,
164
+ createdAt: record.createdAt,
165
+ title: record.title,
166
+ running: Date.now() - Date.parse(record.updatedAt) < 10_000,
167
+ }));
168
+ const nativeSessions = getNativeGeminiSessions(resolvedWorkdir);
169
+ // Merge: pikiclaw records take precedence
170
+ // Filter out pending sessions — they haven't been confirmed by the agent yet
171
+ const seen = new Set();
172
+ const merged = [];
173
+ for (const s of pikiclawSessions) {
174
+ if (isPendingSessionId(s.sessionId))
175
+ continue;
176
+ if (s.sessionId)
177
+ seen.add(s.sessionId);
178
+ merged.push(s);
179
+ }
180
+ for (const s of nativeSessions) {
181
+ if (s.sessionId && !seen.has(s.sessionId))
182
+ merged.push(s);
183
+ }
184
+ merged.sort((a, b) => Date.parse(b.createdAt || '') - Date.parse(a.createdAt || ''));
185
+ const sessions = typeof limit === 'number' ? merged.slice(0, limit) : merged;
186
+ const projectName = geminiProjectName(resolvedWorkdir);
187
+ const chatsDir = projectName ? path.join(process.env.HOME || '', '.gemini', 'tmp', projectName, 'chats') : '';
188
+ agentLog(`[sessions:gemini] workdir=${resolvedWorkdir} projectName=${projectName || '(none)'} chatsDir=${chatsDir || '(none)'} ` +
189
+ `chatsDirExists=${chatsDir ? fs.existsSync(chatsDir) : false} pikiclaw=${pikiclawSessions.length} native=${nativeSessions.length} merged=${sessions.length}`);
190
+ return { ok: true, sessions, error: null };
191
+ }
192
+ // ---------------------------------------------------------------------------
193
+ // Models — static list for now, can be extended with `gemini models list`
194
+ // ---------------------------------------------------------------------------
195
+ // Model IDs from gemini-cli-core (no CLI command to list them dynamically)
196
+ const GEMINI_MODELS = [
197
+ { id: 'auto-gemini-3', alias: 'auto-3' },
198
+ { id: 'auto-gemini-2.5', alias: 'auto' },
199
+ { id: 'gemini-3.1-pro-preview', alias: '3.1-pro' },
200
+ { id: 'gemini-3-pro-preview', alias: '3-pro' },
201
+ { id: 'gemini-3-flash-preview', alias: '3-flash' },
202
+ { id: 'gemini-2.5-pro', alias: 'pro' },
203
+ { id: 'gemini-2.5-flash', alias: 'flash' },
204
+ { id: 'gemini-2.5-flash-lite', alias: 'flash-lite' },
205
+ ];
206
+ // ---------------------------------------------------------------------------
207
+ // Driver
208
+ // ---------------------------------------------------------------------------
209
+ class GeminiDriver {
210
+ id = 'gemini';
211
+ cmd = 'gemini';
212
+ thinkLabel = 'Thinking';
213
+ detect() { return detectAgentBin('gemini', 'gemini'); }
214
+ async doStream(opts) { return doGeminiStream(opts); }
215
+ async getSessions(workdir, limit) {
216
+ return getGeminiSessions(workdir, limit);
217
+ }
218
+ async getSessionTail(opts) {
219
+ // TODO: implement gemini session tail reading once protocol is known
220
+ return { ok: true, messages: [], error: null };
221
+ }
222
+ async listModels(_opts) {
223
+ return { agent: 'gemini', models: [...GEMINI_MODELS], sources: [], note: null };
224
+ }
225
+ getUsage(_opts) {
226
+ return emptyUsage('gemini', 'Gemini usage inspection not yet implemented.');
227
+ }
228
+ shutdown() { }
229
+ }
230
+ registerDriver(new GeminiDriver());
@@ -0,0 +1,192 @@
1
+ /**
2
+ * mcp-bridge.ts — MCP session bridge orchestrator.
3
+ *
4
+ * Runs inside the main pikiclaw process. For each agent stream:
5
+ * 1. Starts a tiny HTTP callback server on localhost (random port).
6
+ * 2. Writes an MCP config JSON pointing to `pikiclaw --mcp-serve`.
7
+ * 3. The agent CLI spawns the MCP server via --mcp-config.
8
+ * 4. When the agent calls `send_file`, the MCP server POSTs to our callback.
9
+ * 5. We forward the request to the IM channel and respond with success/failure.
10
+ *
11
+ * Lifecycle: one bridge per stream, created before spawn, stopped after stream ends.
12
+ */
13
+ import http from 'node:http';
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import { execFileSync } from 'node:child_process';
17
+ // ---------------------------------------------------------------------------
18
+ // Resolve the MCP server entry script path
19
+ // ---------------------------------------------------------------------------
20
+ /**
21
+ * Find the compiled mcp-session-server.js next to this file's compiled output.
22
+ * Falls back to running via the CLI entry point with --mcp-serve.
23
+ */
24
+ function resolveMcpServerCommand() {
25
+ // Try to find the compiled JS file in the same directory as this module
26
+ const thisDir = path.dirname(new URL(import.meta.url).pathname);
27
+ const serverScript = path.join(thisDir, 'mcp-session-server.js');
28
+ if (fs.existsSync(serverScript)) {
29
+ return { command: 'node', args: [serverScript] };
30
+ }
31
+ // Fallback: use pikiclaw CLI with --mcp-serve flag
32
+ const cliScript = path.join(thisDir, 'cli.js');
33
+ if (fs.existsSync(cliScript)) {
34
+ return { command: 'node', args: [cliScript, '--mcp-serve'] };
35
+ }
36
+ // Last resort: assume pikiclaw is in PATH
37
+ return { command: 'pikiclaw', args: ['--mcp-serve'] };
38
+ }
39
+ // ---------------------------------------------------------------------------
40
+ // Bridge implementation
41
+ // ---------------------------------------------------------------------------
42
+ const ARTIFACT_MAX_BYTES = 20 * 1024 * 1024;
43
+ const PHOTO_EXTS = new Set(['.jpg', '.jpeg', '.png', '.webp']);
44
+ function isPhotoFile(filePath) {
45
+ return PHOTO_EXTS.has(path.extname(filePath).toLowerCase());
46
+ }
47
+ /** Check if realFile is inside any of the allowed root directories. */
48
+ function isInsideAllowedRoot(realFile, allowedRoots) {
49
+ for (const root of allowedRoots) {
50
+ try {
51
+ const realRoot = fs.realpathSync(root);
52
+ const rel = path.relative(realRoot, realFile);
53
+ if (rel && !rel.startsWith('..') && !path.isAbsolute(rel))
54
+ return true;
55
+ }
56
+ catch { /* root doesn't exist, skip */ }
57
+ }
58
+ return false;
59
+ }
60
+ export async function startMcpBridge(opts) {
61
+ const { sessionDir, workspacePath, stagedFiles, sendFile } = opts;
62
+ // Build allowed roots: workspace + workdir + /tmp
63
+ const allowedRoots = [workspacePath];
64
+ if (opts.workdir)
65
+ allowedRoots.push(opts.workdir);
66
+ allowedRoots.push('/tmp');
67
+ // ── HTTP callback server ──
68
+ const server = http.createServer((req, res) => {
69
+ if (req.method !== 'POST' || req.url !== '/send-file') {
70
+ res.writeHead(404);
71
+ res.end();
72
+ return;
73
+ }
74
+ let body = '';
75
+ req.on('data', (chunk) => { body += chunk; });
76
+ req.on('end', async () => {
77
+ try {
78
+ const data = JSON.parse(body);
79
+ const relPath = String(data.path || '').trim();
80
+ if (!relPath) {
81
+ res.writeHead(400, { 'Content-Type': 'application/json' });
82
+ res.end(JSON.stringify({ ok: false, error: 'path is required' }));
83
+ return;
84
+ }
85
+ // Resolve and validate path
86
+ const absPath = path.isAbsolute(relPath) ? relPath : path.resolve(workspacePath, relPath);
87
+ let realFile;
88
+ try {
89
+ realFile = fs.realpathSync(absPath);
90
+ }
91
+ catch {
92
+ res.writeHead(400, { 'Content-Type': 'application/json' });
93
+ res.end(JSON.stringify({ ok: false, error: `file not found: ${relPath}` }));
94
+ return;
95
+ }
96
+ if (!isInsideAllowedRoot(realFile, allowedRoots)) {
97
+ res.writeHead(403, { 'Content-Type': 'application/json' });
98
+ res.end(JSON.stringify({ ok: false, error: 'file must be inside the workspace, workdir, or /tmp' }));
99
+ return;
100
+ }
101
+ // Size check
102
+ const stat = fs.statSync(realFile);
103
+ if (!stat.isFile()) {
104
+ res.writeHead(400, { 'Content-Type': 'application/json' });
105
+ res.end(JSON.stringify({ ok: false, error: 'not a regular file' }));
106
+ return;
107
+ }
108
+ if (stat.size > ARTIFACT_MAX_BYTES) {
109
+ res.writeHead(400, { 'Content-Type': 'application/json' });
110
+ res.end(JSON.stringify({ ok: false, error: `file too large (${stat.size} bytes, max ${ARTIFACT_MAX_BYTES})` }));
111
+ return;
112
+ }
113
+ // Auto-detect kind
114
+ const kind = data.kind === 'photo' ? 'photo'
115
+ : data.kind === 'document' ? 'document'
116
+ : isPhotoFile(realFile) ? 'photo'
117
+ : 'document';
118
+ const caption = typeof data.caption === 'string' ? data.caption.trim().slice(0, 1024) || undefined : undefined;
119
+ const result = await sendFile(realFile, { caption, kind });
120
+ res.writeHead(200, { 'Content-Type': 'application/json' });
121
+ res.end(JSON.stringify(result));
122
+ }
123
+ catch (e) {
124
+ res.writeHead(500, { 'Content-Type': 'application/json' });
125
+ res.end(JSON.stringify({ ok: false, error: e?.message || 'internal error' }));
126
+ }
127
+ });
128
+ });
129
+ await new Promise((resolve, reject) => {
130
+ server.on('error', reject);
131
+ server.listen(0, '127.0.0.1', () => resolve());
132
+ });
133
+ const port = server.address().port;
134
+ // ── Register MCP server with the agent ──
135
+ const { command, args } = resolveMcpServerCommand();
136
+ const envVars = {
137
+ MCP_WORKSPACE_PATH: workspacePath,
138
+ MCP_STAGED_FILES: JSON.stringify(stagedFiles),
139
+ MCP_CALLBACK_URL: `http://127.0.0.1:${port}`,
140
+ };
141
+ let configPath = '';
142
+ let codexRegistered = false;
143
+ if (opts.agent === 'codex') {
144
+ // Codex: register MCP server via `codex mcp add/remove`
145
+ const codexArgs = ['mcp', 'add', 'pikiclaw'];
146
+ for (const [k, v] of Object.entries(envVars))
147
+ codexArgs.push('--env', `${k}=${v}`);
148
+ codexArgs.push('--', command, ...args);
149
+ try {
150
+ execFileSync('codex', codexArgs, { stdio: 'pipe', timeout: 10_000 });
151
+ codexRegistered = true;
152
+ }
153
+ catch (e) {
154
+ // If already exists, remove and retry
155
+ try {
156
+ execFileSync('codex', ['mcp', 'remove', 'pikiclaw'], { stdio: 'pipe', timeout: 5_000 });
157
+ }
158
+ catch { }
159
+ execFileSync('codex', codexArgs, { stdio: 'pipe', timeout: 10_000 });
160
+ codexRegistered = true;
161
+ }
162
+ }
163
+ else {
164
+ // Claude/Gemini: write MCP config JSON for --mcp-config
165
+ configPath = path.join(sessionDir, 'mcp-config.json');
166
+ const config = {
167
+ mcpServers: {
168
+ pikiclaw: { command, args, env: envVars },
169
+ },
170
+ };
171
+ fs.mkdirSync(sessionDir, { recursive: true });
172
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
173
+ }
174
+ return {
175
+ configPath,
176
+ stop: async () => {
177
+ await new Promise(resolve => server.close(() => resolve()));
178
+ if (codexRegistered) {
179
+ try {
180
+ execFileSync('codex', ['mcp', 'remove', 'pikiclaw'], { stdio: 'pipe', timeout: 5_000 });
181
+ }
182
+ catch { }
183
+ }
184
+ if (configPath) {
185
+ try {
186
+ fs.rmSync(configPath, { force: true });
187
+ }
188
+ catch { }
189
+ }
190
+ },
191
+ };
192
+ }