icopilot 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +250 -0
- package/LICENSE +21 -0
- package/README.md +214 -0
- package/bin/icopilot.js +6 -0
- package/dist/acp/router.js +123 -0
- package/dist/acp/schema.js +53 -0
- package/dist/agents/aggregator.js +187 -0
- package/dist/agents/custom-agents.js +97 -0
- package/dist/agents/goal-driven.js +411 -0
- package/dist/agents/multi-repo.js +350 -0
- package/dist/agents/parallel-runner.js +181 -0
- package/dist/agents/router.js +144 -0
- package/dist/agents/self-heal.js +481 -0
- package/dist/agents/tdd-agent.js +278 -0
- package/dist/api/github-models.js +158 -0
- package/dist/bridge/ide-bridge.js +479 -0
- package/dist/cloud/routine-executor.js +34 -0
- package/dist/cloud/routine-scheduler.js +67 -0
- package/dist/cloud/routine-storage.js +297 -0
- package/dist/commands/acp-cmd.js +143 -0
- package/dist/commands/actions-cmd.js +624 -0
- package/dist/commands/agent-cmd.js +144 -0
- package/dist/commands/alias-cmd.js +132 -0
- package/dist/commands/bookmark-cmd.js +77 -0
- package/dist/commands/changelog-cmd.js +99 -0
- package/dist/commands/changes-cmd.js +120 -0
- package/dist/commands/clipboard-cmd.js +217 -0
- package/dist/commands/cloud-routine-cmd.js +265 -0
- package/dist/commands/codegen-cmd.js +544 -0
- package/dist/commands/compare-cmd.js +116 -0
- package/dist/commands/context-cmd.js +247 -0
- package/dist/commands/context-viz-cmd.js +43 -0
- package/dist/commands/conventions-cmd.js +116 -0
- package/dist/commands/cost-cmd.js +51 -0
- package/dist/commands/deps-cmd.js +294 -0
- package/dist/commands/diagram-cmd.js +658 -0
- package/dist/commands/diff-review-cmd.js +92 -0
- package/dist/commands/doc-cmd.js +412 -0
- package/dist/commands/doctor-cmd.js +152 -0
- package/dist/commands/editor-cmd.js +49 -0
- package/dist/commands/env-cmd.js +86 -0
- package/dist/commands/explain-cmd.js +78 -0
- package/dist/commands/explain-shell-cmd.js +22 -0
- package/dist/commands/explore-cmd.js +231 -0
- package/dist/commands/feedback-cmd.js +98 -0
- package/dist/commands/fix-cmd.js +17 -0
- package/dist/commands/generate-cmd.js +38 -0
- package/dist/commands/git-extra.js +197 -0
- package/dist/commands/git-log-cmd.js +98 -0
- package/dist/commands/git-undo-cmd.js +137 -0
- package/dist/commands/git.js +155 -0
- package/dist/commands/history-cmd.js +122 -0
- package/dist/commands/index-cmd.js +65 -0
- package/dist/commands/init-cmd.js +73 -0
- package/dist/commands/lint-cmd.js +133 -0
- package/dist/commands/memory-cmd.js +98 -0
- package/dist/commands/metrics-cmd.js +97 -0
- package/dist/commands/mode-prefix.js +30 -0
- package/dist/commands/multi-cmd.js +44 -0
- package/dist/commands/notify-cmd.js +204 -0
- package/dist/commands/profile-cmd.js +101 -0
- package/dist/commands/prompts.js +17 -0
- package/dist/commands/rag-cmd.js +60 -0
- package/dist/commands/readme-cmd.js +564 -0
- package/dist/commands/reasoning-cmd.js +34 -0
- package/dist/commands/refactor-cmd.js +96 -0
- package/dist/commands/release-cmd.js +450 -0
- package/dist/commands/repo-cmd.js +195 -0
- package/dist/commands/route-cmd.js +21 -0
- package/dist/commands/schedule-cmd.js +109 -0
- package/dist/commands/search-cmd.js +47 -0
- package/dist/commands/security-cmd.js +156 -0
- package/dist/commands/settings-cmd.js +238 -0
- package/dist/commands/skill-cmd.js +338 -0
- package/dist/commands/slash.js +2721 -0
- package/dist/commands/snippets-cmd.js +83 -0
- package/dist/commands/space-cmd.js +92 -0
- package/dist/commands/stash-cmd.js +156 -0
- package/dist/commands/stats-cmd.js +36 -0
- package/dist/commands/style-cmd.js +85 -0
- package/dist/commands/suggest-cmd.js +40 -0
- package/dist/commands/summary-cmd.js +138 -0
- package/dist/commands/task-cmd.js +58 -0
- package/dist/commands/team-memory-cmd.js +97 -0
- package/dist/commands/template-cmd.js +475 -0
- package/dist/commands/test-cmd.js +146 -0
- package/dist/commands/todo-cmd.js +172 -0
- package/dist/commands/tokens-cmd.js +277 -0
- package/dist/commands/trigger-cmd.js +147 -0
- package/dist/commands/undo-cmd.js +18 -0
- package/dist/commands/voice-cmd.js +89 -0
- package/dist/commands/watch-cmd.js +110 -0
- package/dist/commands/web-cmd.js +183 -0
- package/dist/commands/worktree-cmd.js +119 -0
- package/dist/config-profile.js +66 -0
- package/dist/config.js +288 -0
- package/dist/context/compactor.js +53 -0
- package/dist/context/dep-context.js +329 -0
- package/dist/context/file-refs.js +54 -0
- package/dist/context/git-context.js +229 -0
- package/dist/context/image-input.js +66 -0
- package/dist/context/memory.js +55 -0
- package/dist/context/persistent-memory.js +104 -0
- package/dist/context/pinned.js +96 -0
- package/dist/context/priority.js +150 -0
- package/dist/context/read-only.js +48 -0
- package/dist/context/smart-files.js +286 -0
- package/dist/context/team-memory.js +156 -0
- package/dist/extensions/loader.js +149 -0
- package/dist/extensions/marketplace.js +49 -0
- package/dist/extensions/slack-provider.js +181 -0
- package/dist/extensions/team.js +56 -0
- package/dist/extensions/teams-provider.js +222 -0
- package/dist/extensions/voice.js +18 -0
- package/dist/hooks/lifecycle.js +215 -0
- package/dist/hooks/precommit.js +463 -0
- package/dist/index/embeddings.js +23 -0
- package/dist/index/indexer.js +86 -0
- package/dist/index/retrieve.js +20 -0
- package/dist/index/store.js +95 -0
- package/dist/index.js +286 -0
- package/dist/intelligence/dead-code.js +457 -0
- package/dist/intelligence/error-watch.js +263 -0
- package/dist/intelligence/navigation.js +141 -0
- package/dist/intelligence/stack-trace.js +210 -0
- package/dist/intelligence/symbol-index.js +410 -0
- package/dist/knowledge/auto-memory.js +412 -0
- package/dist/knowledge/conventions.js +475 -0
- package/dist/knowledge/corrections.js +213 -0
- package/dist/knowledge/rag.js +450 -0
- package/dist/knowledge/style-learner.js +324 -0
- package/dist/logger.js +35 -0
- package/dist/mcp/client.js +144 -0
- package/dist/mcp/config.js +24 -0
- package/dist/mcp/index.js +89 -0
- package/dist/modes/auto-compact.js +20 -0
- package/dist/modes/autopilot.js +157 -0
- package/dist/modes/background.js +82 -0
- package/dist/modes/interactive.js +187 -0
- package/dist/modes/oneshot.js +36 -0
- package/dist/modes/tui.js +265 -0
- package/dist/modes/turn.js +342 -0
- package/dist/notifications/manager.js +107 -0
- package/dist/plugins/marketplace.js +244 -0
- package/dist/providers/custom-provider.js +298 -0
- package/dist/providers/local-model.js +121 -0
- package/dist/routing/profiles.js +44 -0
- package/dist/routing/router.js +18 -0
- package/dist/sandbox/container.js +151 -0
- package/dist/security/audit.js +237 -0
- package/dist/security/content-filter.js +449 -0
- package/dist/security/proxy.js +301 -0
- package/dist/security/retention.js +281 -0
- package/dist/security/roles.js +252 -0
- package/dist/server/api-server.js +679 -0
- package/dist/session/bookmarks.js +72 -0
- package/dist/session/cloud-session.js +291 -0
- package/dist/session/handoff.js +405 -0
- package/dist/session/manager.js +35 -0
- package/dist/session/session.js +296 -0
- package/dist/session/share.js +313 -0
- package/dist/session/undo-journal.js +91 -0
- package/dist/snippets/store.js +60 -0
- package/dist/spaces/space-config.js +156 -0
- package/dist/spaces/space.js +220 -0
- package/dist/stats/store.js +101 -0
- package/dist/tools/apply-patch.js +134 -0
- package/dist/tools/auto-check.js +218 -0
- package/dist/tools/diff-edit.js +150 -0
- package/dist/tools/diff-prompt.js +36 -0
- package/dist/tools/edit-file.js +66 -0
- package/dist/tools/file-ops.js +205 -0
- package/dist/tools/glob.js +17 -0
- package/dist/tools/grep.js +56 -0
- package/dist/tools/image.js +194 -0
- package/dist/tools/list-directory.js +228 -0
- package/dist/tools/memory.js +17 -0
- package/dist/tools/multi-edit.js +299 -0
- package/dist/tools/policy.js +95 -0
- package/dist/tools/registry.js +484 -0
- package/dist/tools/retry.js +74 -0
- package/dist/tools/run-in-terminal.js +162 -0
- package/dist/tools/safety.js +64 -0
- package/dist/tools/sandbox.js +15 -0
- package/dist/tools/search-symbols.js +212 -0
- package/dist/tools/shell.js +118 -0
- package/dist/tools/web.js +167 -0
- package/dist/ui/prompt.js +37 -0
- package/dist/ui/render.js +96 -0
- package/dist/ui/screen.js +13 -0
- package/dist/ui/theme.js +56 -0
- package/dist/util/browser.js +34 -0
- package/dist/util/completion.js +350 -0
- package/dist/util/cost.js +28 -0
- package/dist/util/keybindings.js +113 -0
- package/dist/util/lazy.js +26 -0
- package/dist/util/perf.js +25 -0
- package/dist/util/token-worker.js +11 -0
- package/dist/util/tokens.js +50 -0
- package/dist/workflows/builtins.js +128 -0
- package/dist/workflows/engine.js +496 -0
- package/dist/workflows/file-trigger.js +197 -0
- package/package.json +79 -0
|
@@ -0,0 +1,679 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { createHmac } from 'node:crypto';
|
|
4
|
+
import { URL } from 'node:url';
|
|
5
|
+
import { parseFileRefs, renderFileRefBlock } from '../context/file-refs.js';
|
|
6
|
+
import { buildImageContent, detectImagePaths, isVisionCapableModel, } from '../context/image-input.js';
|
|
7
|
+
import { streamChat } from '../api/github-models.js';
|
|
8
|
+
import { config } from '../config.js';
|
|
9
|
+
import { buildSystemPrompt } from '../modes/turn.js';
|
|
10
|
+
import { Session } from '../session/session.js';
|
|
11
|
+
import { TOOL_SCHEMAS, dispatchTool } from '../tools/registry.js';
|
|
12
|
+
import { AcpRouter } from '../acp/router.js';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import os from 'node:os';
|
|
15
|
+
const MAX_TOOL_HOPS = 6;
|
|
16
|
+
export const DEFAULT_API_PORT = 8787;
|
|
17
|
+
export class APIServer {
|
|
18
|
+
server = null;
|
|
19
|
+
sessions = new Map();
|
|
20
|
+
activeSessionId = null;
|
|
21
|
+
startedAt = Date.now();
|
|
22
|
+
acpRouter = new AcpRouter({
|
|
23
|
+
onLog: (level, message, data) => {
|
|
24
|
+
if (config.verbose) {
|
|
25
|
+
console.error(`[ACP ${level.toUpperCase()}] ${message}`, data || '');
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
async start(port = DEFAULT_API_PORT) {
|
|
30
|
+
if (this.server) {
|
|
31
|
+
return this.getPort() ?? port;
|
|
32
|
+
}
|
|
33
|
+
this.server = http.createServer(async (req, res) => {
|
|
34
|
+
await this.route(req, res);
|
|
35
|
+
});
|
|
36
|
+
await new Promise((resolve, reject) => {
|
|
37
|
+
this.server.once('error', reject);
|
|
38
|
+
this.server.listen(port, () => {
|
|
39
|
+
this.server.off('error', reject);
|
|
40
|
+
resolve();
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
return this.getPort() ?? port;
|
|
44
|
+
}
|
|
45
|
+
async stop() {
|
|
46
|
+
if (!this.server)
|
|
47
|
+
return;
|
|
48
|
+
const server = this.server;
|
|
49
|
+
this.server = null;
|
|
50
|
+
await new Promise((resolve, reject) => {
|
|
51
|
+
server.close((error) => {
|
|
52
|
+
if (error)
|
|
53
|
+
reject(error);
|
|
54
|
+
else
|
|
55
|
+
resolve();
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
isRunning() {
|
|
60
|
+
return this.server !== null;
|
|
61
|
+
}
|
|
62
|
+
getPort() {
|
|
63
|
+
const address = this.server?.address();
|
|
64
|
+
return address && typeof address === 'object' ? address.port : undefined;
|
|
65
|
+
}
|
|
66
|
+
getSessionCount() {
|
|
67
|
+
return this.sessions.size;
|
|
68
|
+
}
|
|
69
|
+
async route(req, res) {
|
|
70
|
+
this.setCorsHeaders(res);
|
|
71
|
+
if (req.method === 'OPTIONS') {
|
|
72
|
+
res.writeHead(204);
|
|
73
|
+
res.end();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const requestUrl = new URL(req.url || '/', 'http://127.0.0.1');
|
|
77
|
+
const pathname = requestUrl.pathname;
|
|
78
|
+
if (!this.isAuthorized(req, pathname)) {
|
|
79
|
+
this.writeJson(res, 401, { error: 'Unauthorized' });
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
if (req.method === 'GET' && pathname === '/api/health') {
|
|
84
|
+
this.writeJson(res, 200, {
|
|
85
|
+
ok: true,
|
|
86
|
+
status: 'ok',
|
|
87
|
+
port: this.getPort() ?? DEFAULT_API_PORT,
|
|
88
|
+
uptimeMs: Date.now() - this.startedAt,
|
|
89
|
+
authRequired: this.requiresApiKey(),
|
|
90
|
+
sessionCount: this.getSessionCount(),
|
|
91
|
+
});
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (req.method === 'POST' && pathname === '/acp') {
|
|
95
|
+
await this.handleAcpRequest(req, res);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (req.method === 'GET' && pathname === '/') {
|
|
99
|
+
this.writeHtml(res, renderWebUiShell(this.requiresApiKey()));
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (req.method === 'GET' && pathname === '/favicon.ico') {
|
|
103
|
+
res.writeHead(204);
|
|
104
|
+
res.end();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (req.method === 'GET' && pathname === '/api/models') {
|
|
108
|
+
const activeModels = [
|
|
109
|
+
...new Set([...this.sessions.values()].map((session) => session.state.model)),
|
|
110
|
+
];
|
|
111
|
+
this.writeJson(res, 200, {
|
|
112
|
+
defaultModel: config.defaultModel,
|
|
113
|
+
endpoint: config.endpoint,
|
|
114
|
+
models: [...new Set([config.defaultModel, ...activeModels])],
|
|
115
|
+
});
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (req.method === 'GET' && pathname === '/api/session') {
|
|
119
|
+
const sessionId = requestUrl.searchParams.get('id') || undefined;
|
|
120
|
+
if (sessionId) {
|
|
121
|
+
const session = this.resolveSession(sessionId);
|
|
122
|
+
if (!session) {
|
|
123
|
+
this.writeJson(res, 404, { error: `Session not found: ${sessionId}` });
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
this.writeJson(res, 200, { session: serializeSession(session) });
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const current = this.resolveSession();
|
|
130
|
+
this.writeJson(res, 200, {
|
|
131
|
+
current: current ? serializeSession(current) : null,
|
|
132
|
+
recent: Session.list().slice(0, 10),
|
|
133
|
+
});
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (req.method === 'POST' && pathname === '/api/session/new') {
|
|
137
|
+
const body = await this.readJson(req);
|
|
138
|
+
const session = new Session({
|
|
139
|
+
cwd: typeof body.cwd === 'string' ? body.cwd : config.cwd,
|
|
140
|
+
model: typeof body.model === 'string' ? body.model : config.defaultModel,
|
|
141
|
+
mode: body.mode === 'plan' ? 'plan' : 'ask',
|
|
142
|
+
});
|
|
143
|
+
if (typeof body.systemPrompt === 'string' && body.systemPrompt.trim()) {
|
|
144
|
+
session.setSystemPrompt(body.systemPrompt.trim());
|
|
145
|
+
}
|
|
146
|
+
await session.initializeGitContext().catch(() => []);
|
|
147
|
+
this.trackSession(session);
|
|
148
|
+
this.writeJson(res, 201, { session: serializeSession(session) });
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (req.method === 'POST' && pathname === '/api/command') {
|
|
152
|
+
const body = await this.readJson(req);
|
|
153
|
+
const rawCommand = typeof body.command === 'string' ? body.command.trim() : '';
|
|
154
|
+
if (!rawCommand) {
|
|
155
|
+
this.writeJson(res, 400, { error: 'Missing command.' });
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const session = this.resolveOrCreateSession(body.sessionId);
|
|
159
|
+
const command = rawCommand.startsWith('/') ? rawCommand : `/${rawCommand}`;
|
|
160
|
+
const { handleSlash } = await import('../commands/slash.js');
|
|
161
|
+
const commandResult = await handleSlash(command, {
|
|
162
|
+
session,
|
|
163
|
+
abort: new AbortController(),
|
|
164
|
+
schedulePrompt: async (prompt) => {
|
|
165
|
+
await runSessionChat({
|
|
166
|
+
session,
|
|
167
|
+
userInput: prompt,
|
|
168
|
+
signal: new AbortController().signal,
|
|
169
|
+
});
|
|
170
|
+
},
|
|
171
|
+
exit: () => undefined,
|
|
172
|
+
});
|
|
173
|
+
this.trackSession(session);
|
|
174
|
+
this.writeJson(res, 200, {
|
|
175
|
+
result: commandResult,
|
|
176
|
+
session: serializeSession(session),
|
|
177
|
+
});
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (req.method === 'POST' && pathname === '/api/chat') {
|
|
181
|
+
const body = await this.readJson(req);
|
|
182
|
+
const prompt = typeof body.message === 'string' ? body.message : body.prompt;
|
|
183
|
+
if (typeof prompt !== 'string' || !prompt.trim()) {
|
|
184
|
+
this.writeJson(res, 400, { error: 'Missing message.' });
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const session = this.resolveOrCreateSession(body.sessionId);
|
|
188
|
+
if (typeof body.model === 'string' && body.model.trim()) {
|
|
189
|
+
session.setModel(body.model.trim());
|
|
190
|
+
}
|
|
191
|
+
if (body.mode === 'ask' || body.mode === 'plan') {
|
|
192
|
+
session.setMode(body.mode);
|
|
193
|
+
}
|
|
194
|
+
this.trackSession(session);
|
|
195
|
+
const wantsSSE = body.stream !== false || (req.headers.accept || '').includes('text/event-stream');
|
|
196
|
+
const abort = new AbortController();
|
|
197
|
+
req.on('aborted', () => abort.abort());
|
|
198
|
+
res.on('close', () => {
|
|
199
|
+
if (!res.writableEnded)
|
|
200
|
+
abort.abort();
|
|
201
|
+
});
|
|
202
|
+
if (wantsSSE) {
|
|
203
|
+
this.writeSSEHeaders(res);
|
|
204
|
+
this.writeSSE(res, {
|
|
205
|
+
event: 'session',
|
|
206
|
+
data: {
|
|
207
|
+
sessionId: session.state.id,
|
|
208
|
+
model: session.state.model,
|
|
209
|
+
mode: session.state.mode,
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
try {
|
|
213
|
+
const result = await runSessionChat({
|
|
214
|
+
session,
|
|
215
|
+
userInput: prompt.trim(),
|
|
216
|
+
signal: abort.signal,
|
|
217
|
+
onToken: (delta) => {
|
|
218
|
+
this.writeSSE(res, { event: 'delta', data: { delta } });
|
|
219
|
+
},
|
|
220
|
+
onEvent: (event) => {
|
|
221
|
+
this.writeSSE(res, event);
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
this.writeSSE(res, {
|
|
225
|
+
event: 'done',
|
|
226
|
+
data: {
|
|
227
|
+
content: result.content,
|
|
228
|
+
finishReason: result.finishReason,
|
|
229
|
+
toolCalls: result.toolCalls,
|
|
230
|
+
session: serializeSession(session),
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
res.end();
|
|
234
|
+
}
|
|
235
|
+
catch (error) {
|
|
236
|
+
this.writeSSE(res, { event: 'error', data: formatError(error) });
|
|
237
|
+
res.end();
|
|
238
|
+
}
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
try {
|
|
242
|
+
const result = await runSessionChat({
|
|
243
|
+
session,
|
|
244
|
+
userInput: prompt.trim(),
|
|
245
|
+
signal: abort.signal,
|
|
246
|
+
});
|
|
247
|
+
this.writeJson(res, 200, {
|
|
248
|
+
content: result.content,
|
|
249
|
+
finishReason: result.finishReason,
|
|
250
|
+
toolCalls: result.toolCalls,
|
|
251
|
+
session: serializeSession(session),
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
this.writeJson(res, 500, { error: formatError(error) });
|
|
256
|
+
}
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (req.method === 'POST' && pathname === '/webhooks/slack') {
|
|
260
|
+
await this.handleSlackWebhook(req, res);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (req.method === 'POST' && pathname === '/webhooks/teams') {
|
|
264
|
+
await this.handleTeamsWebhook(req, res);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
this.writeJson(res, 404, { error: `Route not found: ${req.method || 'GET'} ${pathname}` });
|
|
268
|
+
}
|
|
269
|
+
catch (error) {
|
|
270
|
+
this.writeJson(res, 500, { error: formatError(error) });
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
setCorsHeaders(res) {
|
|
274
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
275
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS');
|
|
276
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key');
|
|
277
|
+
res.setHeader('Access-Control-Expose-Headers', 'Content-Type');
|
|
278
|
+
}
|
|
279
|
+
async handleAcpRequest(req, res) {
|
|
280
|
+
const contentType = req.headers['content-type'];
|
|
281
|
+
if (!contentType?.includes('application/json')) {
|
|
282
|
+
this.writeJson(res, 400, {
|
|
283
|
+
jsonrpc: '2.0',
|
|
284
|
+
error: { code: -32700, message: 'Content-Type must be application/json' },
|
|
285
|
+
});
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
try {
|
|
289
|
+
const body = await readBody(req);
|
|
290
|
+
const request = body.trim() ? JSON.parse(body) : {};
|
|
291
|
+
const response = await this.acpRouter.handle(request);
|
|
292
|
+
const statusCode = response.error ? 200 : 200;
|
|
293
|
+
res.writeHead(statusCode, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
294
|
+
res.end(JSON.stringify(response));
|
|
295
|
+
}
|
|
296
|
+
catch (error) {
|
|
297
|
+
const errorMsg = error instanceof SyntaxError ? 'Invalid JSON' : 'Request processing failed';
|
|
298
|
+
this.writeJson(res, 400, {
|
|
299
|
+
jsonrpc: '2.0',
|
|
300
|
+
error: { code: -32700, message: errorMsg },
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
writeJson(res, statusCode, payload) {
|
|
305
|
+
res.writeHead(statusCode, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
306
|
+
res.end(JSON.stringify(payload));
|
|
307
|
+
}
|
|
308
|
+
writeHtml(res, html) {
|
|
309
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
310
|
+
res.end(html);
|
|
311
|
+
}
|
|
312
|
+
writeSSEHeaders(res) {
|
|
313
|
+
res.writeHead(200, {
|
|
314
|
+
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
315
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
316
|
+
Connection: 'keep-alive',
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
writeSSE(res, message) {
|
|
320
|
+
res.write(`event: ${message.event}\n`);
|
|
321
|
+
res.write(`data: ${JSON.stringify(message.data)}\n\n`);
|
|
322
|
+
}
|
|
323
|
+
requiresApiKey() {
|
|
324
|
+
return Boolean(process.env.ICOPILOT_API_KEY?.trim());
|
|
325
|
+
}
|
|
326
|
+
isAuthorized(req, pathname) {
|
|
327
|
+
if (pathname === '/api/health' ||
|
|
328
|
+
pathname === '/' ||
|
|
329
|
+
pathname === '/favicon.ico' ||
|
|
330
|
+
pathname === '/acp' ||
|
|
331
|
+
pathname === '/webhooks/slack' ||
|
|
332
|
+
pathname === '/webhooks/teams') {
|
|
333
|
+
return true;
|
|
334
|
+
}
|
|
335
|
+
const expected = process.env.ICOPILOT_API_KEY?.trim();
|
|
336
|
+
if (!expected)
|
|
337
|
+
return true;
|
|
338
|
+
const xApiKey = req.headers['x-api-key'];
|
|
339
|
+
if (typeof xApiKey === 'string' && xApiKey === expected)
|
|
340
|
+
return true;
|
|
341
|
+
const authHeader = req.headers.authorization;
|
|
342
|
+
if (typeof authHeader === 'string' && authHeader.startsWith('Bearer ')) {
|
|
343
|
+
return authHeader.slice('Bearer '.length).trim() === expected;
|
|
344
|
+
}
|
|
345
|
+
return false;
|
|
346
|
+
}
|
|
347
|
+
async readJson(req) {
|
|
348
|
+
const raw = await readBody(req);
|
|
349
|
+
if (!raw.trim())
|
|
350
|
+
return {};
|
|
351
|
+
return JSON.parse(raw);
|
|
352
|
+
}
|
|
353
|
+
trackSession(session) {
|
|
354
|
+
this.sessions.set(session.state.id, session);
|
|
355
|
+
this.activeSessionId = session.state.id;
|
|
356
|
+
return session;
|
|
357
|
+
}
|
|
358
|
+
resolveSession(sessionId) {
|
|
359
|
+
if (sessionId) {
|
|
360
|
+
const cached = this.sessions.get(sessionId);
|
|
361
|
+
if (cached)
|
|
362
|
+
return cached;
|
|
363
|
+
try {
|
|
364
|
+
return this.trackSession(Session.load(sessionId));
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
if (this.activeSessionId) {
|
|
371
|
+
const current = this.sessions.get(this.activeSessionId);
|
|
372
|
+
if (current)
|
|
373
|
+
return current;
|
|
374
|
+
}
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
resolveOrCreateSession(sessionId) {
|
|
378
|
+
return this.resolveSession(sessionId) ?? this.trackSession(new Session());
|
|
379
|
+
}
|
|
380
|
+
async handleSlackWebhook(req, res) {
|
|
381
|
+
const slackSigningSecret = process.env.ICOPILOT_SLACK_SIGNING_SECRET;
|
|
382
|
+
try {
|
|
383
|
+
const body = await readBody(req);
|
|
384
|
+
const payload = JSON.parse(body);
|
|
385
|
+
if (payload.type === 'url_verification') {
|
|
386
|
+
this.writeJson(res, 200, { challenge: payload.challenge });
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
if (slackSigningSecret) {
|
|
390
|
+
const signature = req.headers['x-slack-signature'];
|
|
391
|
+
const timestamp = req.headers['x-slack-request-timestamp'];
|
|
392
|
+
if (!signature ||
|
|
393
|
+
!timestamp ||
|
|
394
|
+
!this.validateSlackSignature(signature, timestamp, body, slackSigningSecret)) {
|
|
395
|
+
this.writeJson(res, 401, { error: 'Invalid signature' });
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
const { getNotificationHandler } = await import('../extensions/team.js');
|
|
400
|
+
const { SlackNotificationHandler } = await import('../extensions/slack-provider.js');
|
|
401
|
+
const handler = getNotificationHandler();
|
|
402
|
+
if (handler instanceof SlackNotificationHandler) {
|
|
403
|
+
handler.handleWebhookEvent(payload);
|
|
404
|
+
}
|
|
405
|
+
this.writeJson(res, 200, { ok: true });
|
|
406
|
+
}
|
|
407
|
+
catch (error) {
|
|
408
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
409
|
+
process.stderr.write(`[webhooks] Slack webhook error: ${msg}\n`);
|
|
410
|
+
this.writeJson(res, 400, { error: msg });
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
async handleTeamsWebhook(req, res) {
|
|
414
|
+
try {
|
|
415
|
+
const body = await readBody(req);
|
|
416
|
+
const queryParams = new URL(`http://localhost${req.url || '/'}`, 'http://localhost')
|
|
417
|
+
.searchParams;
|
|
418
|
+
const id = queryParams.get('id');
|
|
419
|
+
const approved = req.url?.includes('/approve') ?? false;
|
|
420
|
+
if (!id) {
|
|
421
|
+
this.writeJson(res, 400, { error: 'Missing approval ID' });
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
const { getNotificationHandler } = await import('../extensions/team.js');
|
|
425
|
+
const { TeamsNotificationHandler } = await import('../extensions/teams-provider.js');
|
|
426
|
+
const handler = getNotificationHandler();
|
|
427
|
+
if (handler instanceof TeamsNotificationHandler) {
|
|
428
|
+
const parsedBody = JSON.parse(body);
|
|
429
|
+
const userId = parsedBody.from?.id;
|
|
430
|
+
handler.handleApprovalResponse(id, approved, userId);
|
|
431
|
+
}
|
|
432
|
+
this.writeJson(res, 200, { ok: true });
|
|
433
|
+
}
|
|
434
|
+
catch (error) {
|
|
435
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
436
|
+
process.stderr.write(`[webhooks] Teams webhook error: ${msg}\n`);
|
|
437
|
+
this.writeJson(res, 400, { error: msg });
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
validateSlackSignature(signature, timestamp, body, secret) {
|
|
441
|
+
const baseString = `v0:${timestamp}:${body}`;
|
|
442
|
+
const computed = `v0=${createHmac('sha256', secret).update(baseString).digest('hex')}`;
|
|
443
|
+
return computed === signature;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
let globalServer = null;
|
|
447
|
+
export function getGlobalAPIServer() {
|
|
448
|
+
globalServer ??= new APIServer();
|
|
449
|
+
return globalServer;
|
|
450
|
+
}
|
|
451
|
+
export async function stopGlobalAPIServer() {
|
|
452
|
+
if (!globalServer)
|
|
453
|
+
return;
|
|
454
|
+
await globalServer.stop();
|
|
455
|
+
globalServer = null;
|
|
456
|
+
}
|
|
457
|
+
async function runSessionChat(opts) {
|
|
458
|
+
const { session, userInput, signal, onToken, onEvent } = opts;
|
|
459
|
+
const refs = parseFileRefs(userInput);
|
|
460
|
+
const refBlock = renderFileRefBlock(refs);
|
|
461
|
+
const promptInput = refBlock ? `${userInput}\n\n${refBlock}` : userInput;
|
|
462
|
+
const imagePaths = detectImagePaths(userInput);
|
|
463
|
+
const userContent = buildUserMessageContent(promptInput, imagePaths, session.state.cwd, session.state.model, (warning) => onEvent?.({
|
|
464
|
+
event: 'warning',
|
|
465
|
+
data: { message: warning },
|
|
466
|
+
}));
|
|
467
|
+
const sys = {
|
|
468
|
+
role: 'system',
|
|
469
|
+
content: buildSystemPrompt(session),
|
|
470
|
+
};
|
|
471
|
+
const userMsg = {
|
|
472
|
+
role: 'user',
|
|
473
|
+
content: userContent,
|
|
474
|
+
};
|
|
475
|
+
session.push(userMsg);
|
|
476
|
+
let content = '';
|
|
477
|
+
let finishReason = null;
|
|
478
|
+
let finalToolCalls = [];
|
|
479
|
+
for (let hop = 0; hop < MAX_TOOL_HOPS; hop++) {
|
|
480
|
+
let assistantContent = '';
|
|
481
|
+
const result = await streamChat({
|
|
482
|
+
model: session.state.model,
|
|
483
|
+
messages: [sys, ...session.state.messages],
|
|
484
|
+
tools: session.state.mode === 'ask' ? TOOL_SCHEMAS : undefined,
|
|
485
|
+
signal,
|
|
486
|
+
onToken: (delta) => {
|
|
487
|
+
assistantContent += delta;
|
|
488
|
+
onToken?.(delta);
|
|
489
|
+
},
|
|
490
|
+
});
|
|
491
|
+
content += assistantContent;
|
|
492
|
+
finishReason = result.finishReason;
|
|
493
|
+
finalToolCalls = result.toolCalls;
|
|
494
|
+
session.push({
|
|
495
|
+
role: 'assistant',
|
|
496
|
+
content: assistantContent,
|
|
497
|
+
...(result.toolCalls.length
|
|
498
|
+
? {
|
|
499
|
+
tool_calls: result.toolCalls.map((toolCall) => ({
|
|
500
|
+
id: toolCall.id,
|
|
501
|
+
type: 'function',
|
|
502
|
+
function: {
|
|
503
|
+
name: toolCall.name,
|
|
504
|
+
arguments: toolCall.arguments || '{}',
|
|
505
|
+
},
|
|
506
|
+
})),
|
|
507
|
+
}
|
|
508
|
+
: {}),
|
|
509
|
+
});
|
|
510
|
+
if (!result.toolCalls.length || result.finishReason === 'stop') {
|
|
511
|
+
return { content, finishReason, toolCalls: result.toolCalls };
|
|
512
|
+
}
|
|
513
|
+
onEvent?.({
|
|
514
|
+
event: 'tool-calls',
|
|
515
|
+
data: result.toolCalls,
|
|
516
|
+
});
|
|
517
|
+
for (const toolCall of result.toolCalls) {
|
|
518
|
+
let parsedArgs = {};
|
|
519
|
+
try {
|
|
520
|
+
parsedArgs = toolCall.arguments ? JSON.parse(toolCall.arguments) : {};
|
|
521
|
+
}
|
|
522
|
+
catch {
|
|
523
|
+
parsedArgs = { __raw: toolCall.arguments };
|
|
524
|
+
}
|
|
525
|
+
const toolName = typeof toolCall.name === 'string' ? toolCall.name : String(toolCall.name ?? '');
|
|
526
|
+
const toolArgs = parsedArgs && typeof parsedArgs === 'object' ? parsedArgs : {};
|
|
527
|
+
const output = await dispatchTool(toolName, toolArgs);
|
|
528
|
+
session.push({
|
|
529
|
+
role: 'tool',
|
|
530
|
+
tool_call_id: toolCall.id,
|
|
531
|
+
content: output,
|
|
532
|
+
});
|
|
533
|
+
onEvent?.({
|
|
534
|
+
event: 'tool-result',
|
|
535
|
+
data: {
|
|
536
|
+
id: toolCall.id,
|
|
537
|
+
name: toolCall.name,
|
|
538
|
+
output,
|
|
539
|
+
},
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
return { content, finishReason, toolCalls: finalToolCalls };
|
|
544
|
+
}
|
|
545
|
+
function serializeSession(session) {
|
|
546
|
+
return {
|
|
547
|
+
id: session.state.id,
|
|
548
|
+
createdAt: session.state.createdAt,
|
|
549
|
+
model: session.state.model,
|
|
550
|
+
mode: session.state.mode,
|
|
551
|
+
cwd: session.state.cwd,
|
|
552
|
+
messageCount: session.state.messages.length,
|
|
553
|
+
autopilotEnabled: Boolean(session.state.autopilotEnabled),
|
|
554
|
+
todos: session.state.todos,
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
function buildUserMessageContent(text, imagePaths, cwd, model, onWarning) {
|
|
558
|
+
if (!imagePaths.length)
|
|
559
|
+
return text;
|
|
560
|
+
if (!isVisionCapableModel(model)) {
|
|
561
|
+
onWarning(`model "${model}" does not support image input; ignoring ${imagePaths.length} image reference${imagePaths.length === 1 ? '' : 's'}.`);
|
|
562
|
+
return text;
|
|
563
|
+
}
|
|
564
|
+
const content = [{ type: 'text', text }];
|
|
565
|
+
const resolvedImagePaths = imagePaths.map((imagePath) => resolveImagePath(imagePath, cwd));
|
|
566
|
+
for (const imagePath of resolvedImagePaths) {
|
|
567
|
+
try {
|
|
568
|
+
content.push(...buildImageContent([imagePath]));
|
|
569
|
+
}
|
|
570
|
+
catch (error) {
|
|
571
|
+
onWarning(`unable to attach image ${imagePath}: ${error?.message || error}`);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
return content.length > 1 ? content : text;
|
|
575
|
+
}
|
|
576
|
+
function resolveImagePath(filePath, cwd) {
|
|
577
|
+
if (filePath.startsWith('~/') || filePath.startsWith('~\\')) {
|
|
578
|
+
return path.join(os.homedir(), filePath.slice(2));
|
|
579
|
+
}
|
|
580
|
+
return path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
|
|
581
|
+
}
|
|
582
|
+
function renderWebUiShell(authRequired) {
|
|
583
|
+
const authHint = authRequired
|
|
584
|
+
? 'API key required. Provide it below to call /api/chat.'
|
|
585
|
+
: 'No API key required.';
|
|
586
|
+
return `<!doctype html>
|
|
587
|
+
<html lang="en">
|
|
588
|
+
<head>
|
|
589
|
+
<meta charset="utf-8" />
|
|
590
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
591
|
+
<title>iCopilot Browser UI</title>
|
|
592
|
+
<style>
|
|
593
|
+
:root { color-scheme: dark; }
|
|
594
|
+
body { margin: 0; font-family: ui-sans-serif, system-ui; background: #0b0f17; color: #e6edf3; }
|
|
595
|
+
.container { max-width: 880px; margin: 0 auto; padding: 24px 16px 48px; }
|
|
596
|
+
h1 { margin: 0 0 8px; font-size: 1.3rem; }
|
|
597
|
+
.hint { color: #9fb0c0; margin-bottom: 16px; }
|
|
598
|
+
textarea, input, button { font: inherit; }
|
|
599
|
+
textarea, input { width: 100%; box-sizing: border-box; background: #111826; color: #e6edf3; border: 1px solid #273244; border-radius: 8px; padding: 10px; }
|
|
600
|
+
textarea { min-height: 120px; resize: vertical; }
|
|
601
|
+
.row { display: grid; grid-template-columns: 1fr auto; gap: 8px; margin-top: 8px; }
|
|
602
|
+
button { background: #2563eb; border: 0; border-radius: 8px; color: white; padding: 10px 14px; cursor: pointer; }
|
|
603
|
+
button:disabled { opacity: 0.6; cursor: not-allowed; }
|
|
604
|
+
pre { white-space: pre-wrap; background: #0f172a; border: 1px solid #273244; border-radius: 8px; padding: 12px; margin-top: 16px; }
|
|
605
|
+
</style>
|
|
606
|
+
</head>
|
|
607
|
+
<body>
|
|
608
|
+
<div class="container">
|
|
609
|
+
<h1>iCopilot Browser UI</h1>
|
|
610
|
+
<div class="hint">${authHint}</div>
|
|
611
|
+
<label>API key (optional)</label>
|
|
612
|
+
<input id="apiKey" placeholder="X-API-Key value" />
|
|
613
|
+
<label style="display:block; margin-top:10px;">Message</label>
|
|
614
|
+
<textarea id="prompt" placeholder="Ask iCopilot..."></textarea>
|
|
615
|
+
<div class="row">
|
|
616
|
+
<input id="sessionId" placeholder="sessionId (optional)" />
|
|
617
|
+
<button id="send" type="button">Send</button>
|
|
618
|
+
</div>
|
|
619
|
+
<pre id="output">Ready.</pre>
|
|
620
|
+
</div>
|
|
621
|
+
<script>
|
|
622
|
+
const output = document.getElementById('output');
|
|
623
|
+
const promptEl = document.getElementById('prompt');
|
|
624
|
+
const apiKeyEl = document.getElementById('apiKey');
|
|
625
|
+
const sessionIdEl = document.getElementById('sessionId');
|
|
626
|
+
const sendButton = document.getElementById('send');
|
|
627
|
+
|
|
628
|
+
async function send() {
|
|
629
|
+
const message = promptEl.value.trim();
|
|
630
|
+
if (!message) return;
|
|
631
|
+
sendButton.disabled = true;
|
|
632
|
+
output.textContent = 'Thinking...';
|
|
633
|
+
try {
|
|
634
|
+
const headers = { 'content-type': 'application/json' };
|
|
635
|
+
const key = apiKeyEl.value.trim();
|
|
636
|
+
if (key) headers['x-api-key'] = key;
|
|
637
|
+
const payload = { message, sessionId: sessionIdEl.value.trim() || undefined, stream: false };
|
|
638
|
+
const response = await fetch('/api/chat', {
|
|
639
|
+
method: 'POST',
|
|
640
|
+
headers,
|
|
641
|
+
body: JSON.stringify(payload),
|
|
642
|
+
});
|
|
643
|
+
const json = await response.json();
|
|
644
|
+
if (!response.ok) {
|
|
645
|
+
output.textContent = JSON.stringify(json, null, 2);
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
if (json.session && json.session.id) sessionIdEl.value = json.session.id;
|
|
649
|
+
output.textContent = json.content || '(no content)';
|
|
650
|
+
} catch (error) {
|
|
651
|
+
output.textContent = String(error);
|
|
652
|
+
} finally {
|
|
653
|
+
sendButton.disabled = false;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
sendButton.addEventListener('click', send);
|
|
658
|
+
promptEl.addEventListener('keydown', (event) => {
|
|
659
|
+
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
|
|
660
|
+
event.preventDefault();
|
|
661
|
+
send();
|
|
662
|
+
}
|
|
663
|
+
});
|
|
664
|
+
</script>
|
|
665
|
+
</body>
|
|
666
|
+
</html>`;
|
|
667
|
+
}
|
|
668
|
+
async function readBody(req) {
|
|
669
|
+
let body = '';
|
|
670
|
+
for await (const chunk of req) {
|
|
671
|
+
body += chunk instanceof Buffer ? chunk.toString('utf8') : String(chunk);
|
|
672
|
+
}
|
|
673
|
+
return body;
|
|
674
|
+
}
|
|
675
|
+
function formatError(error) {
|
|
676
|
+
if (error instanceof Error)
|
|
677
|
+
return error.message;
|
|
678
|
+
return typeof error === 'string' ? error : `Unexpected error (${randomUUID()})`;
|
|
679
|
+
}
|