@aion0/forge 0.6.1 → 0.8.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/.forge/mcp.json +8 -0
- package/.forge/worktrees/pipeline-0a33c50d/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-0a33c50d/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-2ba01c10/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-2ba01c10/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-3156a8b3/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-3156a8b3/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-316c6574/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-316c6574/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-44a94121/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-44a94121/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-d1757a50/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-d1757a50/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-d59c2fe2/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-d59c2fe2/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-d6a6ef23/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-d6a6ef23/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-e7f78b7a/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-e7f78b7a/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-e97c13c7/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-e97c13c7/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-ecd7cb0f/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-ecd7cb0f/lib/help-docs/07-projects.md +1 -1
- package/CLAUDE.md +2 -2
- package/RELEASE_NOTES.md +101 -5
- package/app/api/auth/check/route.ts +18 -0
- package/app/api/browser-bridge/route.ts +70 -0
- package/app/api/chat/sessions/[id]/events/route.ts +17 -0
- package/app/api/chat/sessions/[id]/fork/route.ts +15 -0
- package/app/api/chat/sessions/[id]/messages/route.ts +21 -0
- package/app/api/chat/sessions/[id]/route.ts +23 -0
- package/app/api/chat/sessions/route.ts +12 -0
- package/app/api/chat/temper-ping/route.ts +18 -0
- package/app/api/chat-proxy/[...path]/route.ts +83 -0
- package/app/api/connector-tool/route.ts +38 -0
- package/app/api/connectors/[id]/settings/route.ts +112 -0
- package/app/api/connectors/route.ts +108 -0
- package/app/api/health/tools/route.ts +14 -0
- package/app/api/issue-scanner-gitlab/route.ts +95 -0
- package/app/api/jobs/[id]/reset_dedup/route.ts +15 -0
- package/app/api/jobs/[id]/route.ts +31 -0
- package/app/api/jobs/[id]/run/route.ts +44 -0
- package/app/api/jobs/[id]/runs/[runId]/route.ts +15 -0
- package/app/api/jobs/[id]/runs/route.ts +15 -0
- package/app/api/jobs/preview/route.ts +193 -0
- package/app/api/jobs/route.ts +36 -0
- package/app/api/notify/test/route.ts +39 -7
- package/app/api/pipelines/[id]/route.ts +10 -1
- package/app/api/pipelines/route.ts +16 -2
- package/app/api/plugins/route.ts +40 -8
- package/app/api/project-sessions/route.ts +50 -10
- package/app/api/settings/route.ts +13 -0
- package/app/chat/page.tsx +531 -0
- package/bin/forge-server.mjs +3 -1
- package/cli/chat.ts +283 -0
- package/cli/jobs.ts +176 -0
- package/cli/mw.ts +28 -1
- package/cli/worktree.ts +245 -0
- package/components/ConnectorsPanel.tsx +275 -0
- package/components/Dashboard.tsx +90 -37
- package/components/JobsView.tsx +361 -0
- package/components/LogViewer.tsx +12 -2
- package/components/PipelineView.tsx +275 -56
- package/components/PluginsPanel.tsx +3 -1
- package/components/SettingsModal.tsx +229 -40
- package/components/SkillsPanel.tsx +12 -4
- package/components/TerminalLauncher.tsx +3 -1
- package/components/WebTerminal.tsx +32 -9
- package/components/WorkspaceView.tsx +18 -10
- package/docs/Connector-DeclarativeExtract-Handoff.md +471 -0
- package/docs/Connector-DeclarativeExtract-Spec.md +364 -0
- package/docs/Implementation-Plan-Browser-Agent.md +487 -0
- package/docs/Jobs-Design.md +240 -0
- package/docs/LOCAL-DEPLOY.md +3 -3
- package/docs/RFC-Browser-Connectors.md +509 -0
- package/lib/agents/index.ts +44 -6
- package/lib/agents/types.ts +1 -1
- package/lib/browser-bridge-standalone.ts +317 -0
- package/lib/builtin-plugins/github-api.yaml +93 -0
- package/lib/builtin-plugins/gitlab.yaml +860 -0
- package/lib/builtin-plugins/mantis.probe.js +176 -0
- package/lib/builtin-plugins/mantis.yaml +964 -0
- package/lib/builtin-plugins/pmdb.yaml +178 -0
- package/lib/builtin-plugins/teams.yaml +913 -0
- package/lib/chat/__test__/smoke.ts +30 -0
- package/lib/chat/agent-loop.ts +523 -0
- package/lib/chat/bridge-client.ts +59 -0
- package/lib/chat/llm/anthropic.ts +99 -0
- package/lib/chat/llm/index.ts +20 -0
- package/lib/chat/llm/openai.ts +215 -0
- package/lib/chat/llm/types.ts +42 -0
- package/lib/chat/local-memory.ts +300 -0
- package/lib/chat/memory-store.ts +87 -0
- package/lib/chat/memory-tools.ts +157 -0
- package/lib/chat/protocols/http.ts +118 -0
- package/lib/chat/protocols/shell.ts +101 -0
- package/lib/chat/proxy.ts +51 -0
- package/lib/chat/session-store.ts +272 -0
- package/lib/chat/telegram-bridge.ts +276 -0
- package/lib/chat/temper.ts +281 -0
- package/lib/chat/tool-dispatcher.ts +190 -0
- package/lib/chat/types.ts +50 -0
- package/lib/chat-standalone.ts +286 -0
- package/lib/crypto.ts +1 -1
- package/lib/health.ts +131 -0
- package/lib/help-docs/00-overview.md +2 -1
- package/lib/help-docs/01-settings.md +46 -25
- package/lib/help-docs/07-projects.md +1 -1
- package/lib/help-docs/10-troubleshooting.md +10 -2
- package/lib/help-docs/16-gitlab-autofix.md +114 -0
- package/lib/help-docs/17-connectors.md +322 -0
- package/lib/help-docs/18-chrome-mcp.md +134 -0
- package/lib/help-docs/19-jobs.md +140 -0
- package/lib/help-docs/20-mantis-bug-fix.md +115 -0
- package/lib/help-docs/CLAUDE.md +10 -0
- package/lib/init.ts +137 -50
- package/lib/iso-time.ts +30 -0
- package/lib/issue-scanner-gitlab.ts +281 -0
- package/lib/jobs/dispatcher.ts +217 -0
- package/lib/jobs/scheduler.ts +334 -0
- package/lib/jobs/store.ts +319 -0
- package/lib/jobs/types.ts +117 -0
- package/lib/pipeline-scheduler.ts +1 -6
- package/lib/pipeline.ts +790 -10
- package/lib/plugins/registry.ts +133 -8
- package/lib/plugins/templates.ts +83 -0
- package/lib/plugins/types.ts +140 -1
- package/lib/session-watcher.ts +36 -10
- package/lib/settings.ts +65 -33
- package/lib/skills.ts +3 -1
- package/lib/task-manager.ts +50 -22
- package/lib/telegram-bot.ts +71 -0
- package/lib/terminal-standalone.ts +58 -36
- package/lib/workspace/orchestrator.ts +1 -0
- package/middleware.ts +10 -0
- package/package.json +3 -2
- package/scripts/bench/README.md +1 -1
- package/scripts/bench/tasks/01-text-utils/validator.sh +1 -1
- package/scripts/bench/tasks/02-pagination/setup.sh +1 -1
- package/scripts/bench/tasks/02-pagination/validator.sh +1 -1
- package/scripts/bench/tasks/03-bug-fix/setup.sh +1 -1
- package/scripts/bench/tasks/03-bug-fix/validator.sh +1 -1
- package/src/core/db/database.ts +21 -12
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smoke test — run with: npx tsx lib/chat/__test__/smoke.ts
|
|
3
|
+
* No LLM provider needed: the loop should fail-fast with a clear message
|
|
4
|
+
* when no Anthropic/OpenAI key is configured.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { runTurn } from '../agent-loop';
|
|
8
|
+
import { createSession, deleteSession } from '../session-store';
|
|
9
|
+
|
|
10
|
+
async function main() {
|
|
11
|
+
const s = createSession({ title: 'smoke test' });
|
|
12
|
+
console.log('Created session', s.id.slice(0, 8));
|
|
13
|
+
|
|
14
|
+
const events: string[] = [];
|
|
15
|
+
const r = await runTurn({
|
|
16
|
+
sessionId: s.id,
|
|
17
|
+
userText: 'hi',
|
|
18
|
+
callbacks: {
|
|
19
|
+
onEvent: (e) => events.push(e.type + (e.data?.error ? ': ' + String(e.data.error).slice(0, 80) : '')),
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
console.log('Result:', r);
|
|
24
|
+
console.log('Events:', events);
|
|
25
|
+
|
|
26
|
+
deleteSession(s.id);
|
|
27
|
+
console.log('Cleaned up.');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
main();
|
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent loop — runs one turn of the chat: stream the LLM, execute any
|
|
3
|
+
* tool calls (via tool-dispatcher), feed results back, repeat until
|
|
4
|
+
* the model returns text without tool calls (end_turn) or we hit the
|
|
5
|
+
* iteration cap.
|
|
6
|
+
*
|
|
7
|
+
* Ported from the extension's runTurn. Differences vs the extension:
|
|
8
|
+
* - Persistence: messages persist to lib/chat/session-store (sqlite)
|
|
9
|
+
* - Tools: browser tools dispatch via lib/chat/tool-dispatcher → bridge
|
|
10
|
+
* - Memory: Temper integration deferred to Phase 2b
|
|
11
|
+
* - Narrowing / destructive confirm: deferred — agent is simpler v1
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { streamLlm, type LlmTool } from './llm';
|
|
15
|
+
import { loadSettings } from '@/lib/settings';
|
|
16
|
+
import {
|
|
17
|
+
appendMessage,
|
|
18
|
+
getSession,
|
|
19
|
+
listMessages,
|
|
20
|
+
} from './session-store';
|
|
21
|
+
import {
|
|
22
|
+
dispatchTool,
|
|
23
|
+
BUILTIN_TOOL_DEFS,
|
|
24
|
+
type BuiltinHandler,
|
|
25
|
+
} from './tool-dispatcher';
|
|
26
|
+
import { renderMemoryContext } from './temper';
|
|
27
|
+
import { getMemoryStore } from './memory-store';
|
|
28
|
+
import { buildMemoryTools } from './memory-tools';
|
|
29
|
+
import {
|
|
30
|
+
listPlugins,
|
|
31
|
+
getPlugin,
|
|
32
|
+
getInstalledPlugin,
|
|
33
|
+
getConnectorsForPlugin,
|
|
34
|
+
} from '@/lib/plugins/registry';
|
|
35
|
+
import type {
|
|
36
|
+
ContentBlock,
|
|
37
|
+
Message,
|
|
38
|
+
TextBlock,
|
|
39
|
+
ToolResultBlock,
|
|
40
|
+
ToolUseBlock,
|
|
41
|
+
} from './types';
|
|
42
|
+
|
|
43
|
+
const MAX_ITERATIONS = 6;
|
|
44
|
+
const MAX_TOKENS = 16000;
|
|
45
|
+
|
|
46
|
+
export interface AgentEvent {
|
|
47
|
+
type:
|
|
48
|
+
| 'text_delta' // assistant text streaming
|
|
49
|
+
| 'tool_use' // model called a tool
|
|
50
|
+
| 'tool_result' // tool returned
|
|
51
|
+
| 'message_saved' // a full message persisted (assistant or tool-results carrier)
|
|
52
|
+
| 'memory_status' // pinned/blocks/hits snapshot from Temper for the UI strip
|
|
53
|
+
| 'turn_done' // loop finished
|
|
54
|
+
| 'error'; // unrecoverable
|
|
55
|
+
message_id?: string;
|
|
56
|
+
data?: any;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type AgentCallbacks = {
|
|
60
|
+
onEvent: (event: AgentEvent) => void;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
interface ProviderResolution {
|
|
64
|
+
name: string;
|
|
65
|
+
type: 'anthropic' | 'openai';
|
|
66
|
+
apiKey: string;
|
|
67
|
+
baseUrl: string;
|
|
68
|
+
model: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Map the user-facing provider string to a chat adapter protocol.
|
|
73
|
+
* Anthropic's API is the only first-class non-OpenAI shape we ship; LiteLLM /
|
|
74
|
+
* Azure / OpenAI proxies / Grok / Google-via-LiteLLM all speak OpenAI.
|
|
75
|
+
*/
|
|
76
|
+
function inferAdapter(provider: string | undefined): 'anthropic' | 'openai' {
|
|
77
|
+
const p = (provider || '').toLowerCase();
|
|
78
|
+
if (p === 'anthropic' || p === 'claude') return 'anthropic';
|
|
79
|
+
return 'openai';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function pickBaseUrl(profile: { baseUrl?: string; env?: Record<string, string> }, adapter: 'anthropic' | 'openai'): string {
|
|
83
|
+
if (profile.baseUrl) return profile.baseUrl;
|
|
84
|
+
const env = profile.env || {};
|
|
85
|
+
if (adapter === 'anthropic') return env.ANTHROPIC_BASE_URL || '';
|
|
86
|
+
return env.OPENAI_BASE_URL || env.OPENAI_API_BASE || '';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function pickApiKey(profile: { apiKey?: string; env?: Record<string, string> }, adapter: 'anthropic' | 'openai'): string {
|
|
90
|
+
if (profile.apiKey) return profile.apiKey;
|
|
91
|
+
const env = profile.env || {};
|
|
92
|
+
if (adapter === 'anthropic') return env.ANTHROPIC_API_KEY || env.ANTHROPIC_AUTH_TOKEN || '';
|
|
93
|
+
return env.OPENAI_API_KEY || '';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function resolveProvider(sessionProvider: string | null, sessionModel: string | null): ProviderResolution | { error: string } {
|
|
97
|
+
const settings = loadSettings();
|
|
98
|
+
const agents = settings.agents || {};
|
|
99
|
+
|
|
100
|
+
// Candidate API profiles: type=='api', enabled, with a usable apiKey (direct or via env).
|
|
101
|
+
const candidates = Object.entries(agents).filter(([_, a]) => {
|
|
102
|
+
if (!a || a.type !== 'api' || a.enabled === false) return false;
|
|
103
|
+
const adapter = inferAdapter(a.provider);
|
|
104
|
+
return pickApiKey(a, adapter).length > 0;
|
|
105
|
+
});
|
|
106
|
+
if (candidates.length === 0) {
|
|
107
|
+
return { error: 'No API agent profile with an API key. Add one under Settings → Agents (type: API) and pick it as the chat agent.' };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Preferred: session.provider (an agent profile id), then settings.chatAgent, else first candidate.
|
|
111
|
+
const preferredId = sessionProvider || settings.chatAgent || candidates[0]![0];
|
|
112
|
+
const profile = agents[preferredId] && agents[preferredId].type === 'api'
|
|
113
|
+
? agents[preferredId]
|
|
114
|
+
: candidates[0]![1];
|
|
115
|
+
const name = agents[preferredId]?.type === 'api' ? preferredId : candidates[0]![0];
|
|
116
|
+
|
|
117
|
+
const adapter = inferAdapter(profile.provider);
|
|
118
|
+
const apiKey = pickApiKey(profile, adapter);
|
|
119
|
+
if (!apiKey) return { error: `Agent profile "${name}" has no apiKey (direct or via env).` };
|
|
120
|
+
|
|
121
|
+
const model = sessionModel || profile.model || (adapter === 'anthropic' ? 'claude-sonnet-4-6' : 'gpt-4o-mini');
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
name,
|
|
125
|
+
type: adapter,
|
|
126
|
+
apiKey,
|
|
127
|
+
baseUrl: pickBaseUrl(profile, adapter),
|
|
128
|
+
model,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function buildSystemPrompt(connectorTools: LlmTool[], builtinDefs: typeof BUILTIN_TOOL_DEFS, sessionSystemPrompt: string | null): string {
|
|
133
|
+
const now = new Date().toISOString();
|
|
134
|
+
const lines: string[] = [
|
|
135
|
+
"You are Forge, the user's personal AI assistant.",
|
|
136
|
+
'',
|
|
137
|
+
`Current time: ${now}`,
|
|
138
|
+
'',
|
|
139
|
+
'Tool usage — IMPORTANT:',
|
|
140
|
+
'- If the user mentions a system name (e.g. "teams", "mantis", "gitlab", "pmdb") — even casually like "在 teams 中..." / "from mantis" — you MUST attempt the matching connector tool FIRST.',
|
|
141
|
+
' Don\'t explain how to do something manually before trying the tool. The tools below run inside the user\'s actual logged-in browser session — they CAN do things you might think only the user can do manually.',
|
|
142
|
+
'- For Teams in particular: send_message can target any chat by name; if the chat doesn\'t exist yet, the tool will return a specific error and THEN you can advise. Don\'t pre-judge.',
|
|
143
|
+
'- If a tool call fails, read its error carefully — it usually tells you what to fix (wrong arg, missing setting, login required). Retry with the fix. Only give up after the tool explicitly says it cannot do the task.',
|
|
144
|
+
'- Reply without tools ONLY when no system + no time question is involved.',
|
|
145
|
+
'',
|
|
146
|
+
'Other:',
|
|
147
|
+
'- Call get_current_time when asked about "now" or "today".',
|
|
148
|
+
'Keep replies short and direct.',
|
|
149
|
+
];
|
|
150
|
+
|
|
151
|
+
if (connectorTools.length > 0) {
|
|
152
|
+
lines.push('', 'Connector tools available:');
|
|
153
|
+
for (const t of connectorTools) {
|
|
154
|
+
lines.push(`- ${t.name}: ${t.description.slice(0, 100)}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (builtinDefs.length > 0) {
|
|
158
|
+
lines.push('', 'Builtin tools:');
|
|
159
|
+
for (const t of builtinDefs) {
|
|
160
|
+
lines.push(`- ${t.name}: ${t.description.slice(0, 100)}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (sessionSystemPrompt) {
|
|
165
|
+
lines.push('', sessionSystemPrompt);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return lines.join('\n');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Detect connector names the user mentioned in their message so we can narrow
|
|
173
|
+
* the tool list before handing it to the LLM. Three signal strengths:
|
|
174
|
+
*
|
|
175
|
+
* 1. STRONG — slash-prefixed: "/teams" / "/mantis" → user explicitly
|
|
176
|
+
* commanded that connector, narrow to it AND emit a directive line in
|
|
177
|
+
* the system prompt. Multiple slashes ok.
|
|
178
|
+
* 2. MEDIUM — bare alias mention surrounded by non-alphanumeric ("从 teams
|
|
179
|
+
* 读消息" / "in mantis"). Narrow the tool list to those plugins so the
|
|
180
|
+
* LLM can't accidentally wander to a different connector, but no extra
|
|
181
|
+
* directive.
|
|
182
|
+
* 3. NONE — no mention → keep the full tool list.
|
|
183
|
+
*
|
|
184
|
+
* Strong signals subsume medium ones (only the strong set is kept if any
|
|
185
|
+
* exist). Aliases include the plugin id and its name first-word (>=4 chars).
|
|
186
|
+
*/
|
|
187
|
+
function escapeRegExp(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
|
|
188
|
+
|
|
189
|
+
function detectMentionedConnectors(
|
|
190
|
+
userText: string,
|
|
191
|
+
pluginIds: { id: string; name?: string }[],
|
|
192
|
+
): { strong: Set<string>; medium: Set<string> } {
|
|
193
|
+
const strong = new Set<string>();
|
|
194
|
+
const medium = new Set<string>();
|
|
195
|
+
const text = userText || '';
|
|
196
|
+
if (!text) return { strong, medium };
|
|
197
|
+
|
|
198
|
+
for (const p of pluginIds) {
|
|
199
|
+
const aliases = new Set<string>();
|
|
200
|
+
aliases.add(p.id.toLowerCase());
|
|
201
|
+
if (p.name) aliases.add(p.name.toLowerCase());
|
|
202
|
+
if (p.name?.includes(' ')) {
|
|
203
|
+
for (const word of p.name.split(/\s+/)) {
|
|
204
|
+
if (word.length >= 4) aliases.add(word.toLowerCase());
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
for (const alias of aliases) {
|
|
208
|
+
// STRONG — slash-prefix form like "/teams"
|
|
209
|
+
if (new RegExp(`(^|\\s)/${escapeRegExp(alias)}\\b`, 'i').test(text)) {
|
|
210
|
+
strong.add(p.id);
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
// MEDIUM — bare alias, non-alphanumeric boundaries (Chinese chars count
|
|
214
|
+
// as non-alphanumeric, so "从 teams 读消息" matches "teams" but not
|
|
215
|
+
// "teamspeak")
|
|
216
|
+
if (new RegExp(`(^|[^a-zA-Z0-9_])${escapeRegExp(alias)}([^a-zA-Z0-9_]|$)`, 'i').test(text)) {
|
|
217
|
+
medium.add(p.id);
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return { strong, medium };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function buildConnectorTools(): LlmTool[] {
|
|
226
|
+
const out: LlmTool[] = [];
|
|
227
|
+
const sources = listPlugins().filter(s => s.category === 'connector');
|
|
228
|
+
for (const s of sources) {
|
|
229
|
+
const def = getPlugin(s.id);
|
|
230
|
+
if (!def) continue;
|
|
231
|
+
// We deliberately DO NOT require getInstalledPlugin(s.id) here — the LLM
|
|
232
|
+
// should always see all connector tools the user has defined. Install
|
|
233
|
+
// status only matters at dispatch time: browser tools that lack a
|
|
234
|
+
// configured base_url will surface a clear error inside the tool_result
|
|
235
|
+
// (better than vanishing from the tool list and having the LLM make up
|
|
236
|
+
// an excuse like "I can't see your Teams"). http/shell tools don't need
|
|
237
|
+
// install at all.
|
|
238
|
+
for (const entry of getConnectorsForPlugin(def)) {
|
|
239
|
+
for (const [toolName, tool] of Object.entries(entry.tools || {})) {
|
|
240
|
+
// Executable if it has a script (browser protocol) OR a non-browser
|
|
241
|
+
// protocol that runs server-side (http / shell).
|
|
242
|
+
const protocol = (tool as any).protocol;
|
|
243
|
+
const isServerSide = protocol === 'http' || protocol === 'shell';
|
|
244
|
+
if (!tool.script && !isServerSide) continue;
|
|
245
|
+
const properties: Record<string, unknown> = {};
|
|
246
|
+
const required: string[] = [];
|
|
247
|
+
for (const [pname, pdef] of Object.entries(tool.parameters || {})) {
|
|
248
|
+
properties[pname] = fieldToJsonSchema(pdef as any);
|
|
249
|
+
if ((pdef as any)?.required) required.push(pname);
|
|
250
|
+
}
|
|
251
|
+
out.push({
|
|
252
|
+
name: `${s.id}.${toolName}`,
|
|
253
|
+
description: (tool.description || `${s.name} · ${toolName}`).trim(),
|
|
254
|
+
input_schema: {
|
|
255
|
+
type: 'object',
|
|
256
|
+
properties,
|
|
257
|
+
...(required.length > 0 ? { required } : {}),
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return out;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function fieldToJsonSchema(field: { type?: string; description?: string; label?: string; default?: unknown; options?: string[] }): Record<string, unknown> {
|
|
267
|
+
const out: Record<string, unknown> = {};
|
|
268
|
+
switch (field?.type) {
|
|
269
|
+
case 'number': out.type = 'number'; break;
|
|
270
|
+
case 'boolean': out.type = 'boolean'; break;
|
|
271
|
+
case 'select':
|
|
272
|
+
out.type = 'string';
|
|
273
|
+
if (field.options) out.enum = field.options;
|
|
274
|
+
break;
|
|
275
|
+
default: out.type = 'string';
|
|
276
|
+
}
|
|
277
|
+
if (field?.description) out.description = field.description;
|
|
278
|
+
else if (field?.label) out.description = field.label;
|
|
279
|
+
if (field?.default !== undefined) out.default = field.default;
|
|
280
|
+
return out;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ─── Main entry ───────────────────────────────────────────
|
|
284
|
+
|
|
285
|
+
export interface RunTurnArgs {
|
|
286
|
+
sessionId: string;
|
|
287
|
+
userText: string;
|
|
288
|
+
callbacks?: AgentCallbacks;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?: string }> {
|
|
292
|
+
const cb = args.callbacks?.onEvent ?? (() => {});
|
|
293
|
+
const session = getSession(args.sessionId);
|
|
294
|
+
if (!session) {
|
|
295
|
+
cb({ type: 'error', data: { error: `session not found: ${args.sessionId}` } });
|
|
296
|
+
return { ok: false, error: 'session not found' };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const provider = resolveProvider(session.provider, session.model);
|
|
300
|
+
if ('error' in provider) {
|
|
301
|
+
cb({ type: 'error', data: { error: provider.error } });
|
|
302
|
+
return { ok: false, error: provider.error };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Persist user message
|
|
306
|
+
const userMsg = appendMessage({
|
|
307
|
+
session_id: args.sessionId,
|
|
308
|
+
role: 'user',
|
|
309
|
+
blocks: [{ type: 'text', text: args.userText } as TextBlock],
|
|
310
|
+
});
|
|
311
|
+
cb({ type: 'message_saved', message_id: userMsg.id, data: userMsg });
|
|
312
|
+
|
|
313
|
+
// ── Memory (Temper or local SQLite fallback) ───────────────────
|
|
314
|
+
// When Temper is configured we use it; otherwise LocalMemoryStore
|
|
315
|
+
// gives the same block API backed by workflow.db. Either way we
|
|
316
|
+
// pre-fetch pinned + all blocks + a search on the user's message
|
|
317
|
+
// and inline them into the system prompt. memory_* tools are always
|
|
318
|
+
// registered so the LLM can read/write on demand.
|
|
319
|
+
const memStore = getMemoryStore();
|
|
320
|
+
let memContext = '';
|
|
321
|
+
const memTools = buildMemoryTools(memStore);
|
|
322
|
+
const memHandlers: Record<string, BuiltinHandler> = {};
|
|
323
|
+
for (const t of memTools) memHandlers[t.def.name] = t.handle;
|
|
324
|
+
|
|
325
|
+
if (memStore.enabled) {
|
|
326
|
+
const [bp, ba, sp] = await Promise.allSettled([
|
|
327
|
+
memStore.listBlocks({ pinned: true, scope: 'both' }),
|
|
328
|
+
memStore.listBlocks({ scope: 'both' }),
|
|
329
|
+
memStore.search(args.userText, 8),
|
|
330
|
+
]);
|
|
331
|
+
const pinnedBlocks = bp.status === 'fulfilled' ? bp.value : [];
|
|
332
|
+
const allBlocks = ba.status === 'fulfilled' ? ba.value : [];
|
|
333
|
+
const searchHits = sp.status === 'fulfilled' ? sp.value : [];
|
|
334
|
+
const firstErr = [bp, ba, sp].find((r) => r.status === 'rejected') as PromiseRejectedResult | undefined;
|
|
335
|
+
const memError = firstErr ? (firstErr.reason instanceof Error ? firstErr.reason.message : String(firstErr.reason)) : undefined;
|
|
336
|
+
|
|
337
|
+
memContext = renderMemoryContext(allBlocks, searchHits);
|
|
338
|
+
|
|
339
|
+
cb({
|
|
340
|
+
type: 'memory_status',
|
|
341
|
+
data: {
|
|
342
|
+
ts: Date.now(),
|
|
343
|
+
backend: memStore.kind,
|
|
344
|
+
pinnedCount: pinnedBlocks.length,
|
|
345
|
+
blocksCount: allBlocks.length,
|
|
346
|
+
hitsCount: searchHits.filter((h) => h.fact && h.invalid_at == null).length,
|
|
347
|
+
pinnedKeys: pinnedBlocks.map((b) => b.key),
|
|
348
|
+
blockKeys: allBlocks.map((b) => b.key),
|
|
349
|
+
hits: searchHits
|
|
350
|
+
.filter((h) => h.fact && h.invalid_at == null)
|
|
351
|
+
.slice(0, 8)
|
|
352
|
+
.map((h) => (h.fact ?? '').slice(0, 120)),
|
|
353
|
+
error: memError,
|
|
354
|
+
},
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
let connectorTools = buildConnectorTools();
|
|
359
|
+
|
|
360
|
+
// ── Narrowing: if the user named connectors (/teams, "in mantis", ...)
|
|
361
|
+
// restrict the tool list so the LLM can't wander to unrelated connectors.
|
|
362
|
+
// Strong (slash-prefix) signals also emit a directive in the system prompt
|
|
363
|
+
// so the model treats it as a command, not a hint.
|
|
364
|
+
const allPluginIds = [...new Set(connectorTools.map((t) => t.name.split('.')[0]!))];
|
|
365
|
+
const pluginCatalog = allPluginIds.map((id) => {
|
|
366
|
+
const def = getPlugin(id);
|
|
367
|
+
return { id, name: def?.name };
|
|
368
|
+
});
|
|
369
|
+
const mentioned = detectMentionedConnectors(args.userText, pluginCatalog);
|
|
370
|
+
const narrowSet = mentioned.strong.size > 0 ? mentioned.strong
|
|
371
|
+
: mentioned.medium.size > 0 ? mentioned.medium
|
|
372
|
+
: null;
|
|
373
|
+
let narrowDirective = '';
|
|
374
|
+
if (narrowSet) {
|
|
375
|
+
connectorTools = connectorTools.filter((t) => narrowSet.has(t.name.split('.')[0]!));
|
|
376
|
+
const list = [...narrowSet].join(', ');
|
|
377
|
+
if (mentioned.strong.size > 0) {
|
|
378
|
+
narrowDirective = `\n\nUSER MENTIONED CONNECTOR(S) EXPLICITLY (slash-prefix): ${list}. You MUST use these connector tools for this turn — do NOT answer without trying them first, and do NOT consider other connectors.`;
|
|
379
|
+
console.log(`[chat] narrow STRONG → ${list}`);
|
|
380
|
+
} else {
|
|
381
|
+
console.log(`[chat] narrow MEDIUM → ${list}`);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
console.log(
|
|
386
|
+
`[chat] tools=${connectorTools.length} → ` +
|
|
387
|
+
connectorTools.map((t) => t.name).join(', ').slice(0, 600),
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
const builtinDefsAll = [
|
|
391
|
+
...BUILTIN_TOOL_DEFS,
|
|
392
|
+
...memTools.map((m) => m.def),
|
|
393
|
+
];
|
|
394
|
+
const allTools: LlmTool[] = [
|
|
395
|
+
...builtinDefsAll.map((t) => ({
|
|
396
|
+
name: t.name,
|
|
397
|
+
description: t.description,
|
|
398
|
+
input_schema: t.input_schema,
|
|
399
|
+
})),
|
|
400
|
+
...connectorTools,
|
|
401
|
+
];
|
|
402
|
+
|
|
403
|
+
let system = buildSystemPrompt(connectorTools, builtinDefsAll, session.system_prompt);
|
|
404
|
+
if (narrowDirective) system += narrowDirective;
|
|
405
|
+
if (memContext) system += '\n\n─── Memory context (auto-loaded) ───\n' + memContext;
|
|
406
|
+
if (memStore.enabled) {
|
|
407
|
+
const searchHint = memStore.kind === 'local'
|
|
408
|
+
? '• memory_search is keyword LIKE over local blocks + episodes — useful for finding past notes; prefer memory_get_block / memory_list_blocks for first-person facts.'
|
|
409
|
+
: '• memory_search is for third-party graph search — DO NOT use for first-person questions; those are already in the Known facts block.';
|
|
410
|
+
system += `\n\nMEMORY (${memStore.kind === 'temper' ? 'Temper' : 'local SQLite'}):\n` +
|
|
411
|
+
'• When the user states a new preference / identity / role about THEMSELVES → call memory_remember_block in the same turn. After saving, tell the user one short line: "Saved: <key>=<value>".\n' +
|
|
412
|
+
'• For NAMED third-party facts ("Alice prefers Postgres") → call memory_remember_event (one fact per call, name the subject).\n' +
|
|
413
|
+
searchHint;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
let iter = 0;
|
|
417
|
+
let lastStop = '';
|
|
418
|
+
let assistantBlocksAccum: ContentBlock[] = [];
|
|
419
|
+
|
|
420
|
+
try {
|
|
421
|
+
while (iter < MAX_ITERATIONS) {
|
|
422
|
+
iter += 1;
|
|
423
|
+
|
|
424
|
+
const history = listMessages(args.sessionId);
|
|
425
|
+
|
|
426
|
+
assistantBlocksAccum = [];
|
|
427
|
+
let currentTextBuf = '';
|
|
428
|
+
|
|
429
|
+
const result = await streamLlm(
|
|
430
|
+
{
|
|
431
|
+
provider: provider.type,
|
|
432
|
+
apiKey: provider.apiKey,
|
|
433
|
+
baseUrl: provider.baseUrl,
|
|
434
|
+
model: provider.model,
|
|
435
|
+
system,
|
|
436
|
+
history,
|
|
437
|
+
tools: allTools,
|
|
438
|
+
maxTokens: MAX_TOKENS,
|
|
439
|
+
},
|
|
440
|
+
{
|
|
441
|
+
onTextDelta: (delta) => {
|
|
442
|
+
currentTextBuf += delta;
|
|
443
|
+
cb({ type: 'text_delta', data: { delta } });
|
|
444
|
+
},
|
|
445
|
+
onToolUse: (block) => {
|
|
446
|
+
cb({ type: 'tool_use', data: block });
|
|
447
|
+
},
|
|
448
|
+
},
|
|
449
|
+
);
|
|
450
|
+
|
|
451
|
+
lastStop = result.stopReason;
|
|
452
|
+
assistantBlocksAccum = result.content;
|
|
453
|
+
|
|
454
|
+
// Persist assistant message
|
|
455
|
+
const assistantMsg = appendMessage({
|
|
456
|
+
session_id: args.sessionId,
|
|
457
|
+
role: 'assistant',
|
|
458
|
+
blocks: assistantBlocksAccum,
|
|
459
|
+
});
|
|
460
|
+
cb({ type: 'message_saved', message_id: assistantMsg.id, data: assistantMsg });
|
|
461
|
+
|
|
462
|
+
if (result.stopReason !== 'tool_use') break;
|
|
463
|
+
|
|
464
|
+
// Execute tool calls
|
|
465
|
+
const toolUses = result.content.filter((b): b is ToolUseBlock => b.type === 'tool_use');
|
|
466
|
+
const toolResults: ToolResultBlock[] = [];
|
|
467
|
+
for (const t of toolUses) {
|
|
468
|
+
const r = await dispatchTool({ id: t.id, name: t.name, input: t.input }, memHandlers);
|
|
469
|
+
const block: ToolResultBlock = {
|
|
470
|
+
type: 'tool_result',
|
|
471
|
+
tool_use_id: t.id,
|
|
472
|
+
content: r.content,
|
|
473
|
+
is_error: r.is_error,
|
|
474
|
+
};
|
|
475
|
+
toolResults.push(block);
|
|
476
|
+
cb({ type: 'tool_result', data: { tool_use_id: t.id, name: t.name, result: r } });
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Per Anthropic API: tool_result blocks live on a USER message
|
|
480
|
+
const toolResultMsg = appendMessage({
|
|
481
|
+
session_id: args.sessionId,
|
|
482
|
+
role: 'user',
|
|
483
|
+
blocks: toolResults,
|
|
484
|
+
});
|
|
485
|
+
cb({ type: 'message_saved', message_id: toolResultMsg.id, data: toolResultMsg });
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
cb({ type: 'turn_done', data: { iterations: iter, stop_reason: lastStop } });
|
|
489
|
+
return { ok: true };
|
|
490
|
+
} catch (err) {
|
|
491
|
+
let msg = err instanceof Error ? err.message : String(err);
|
|
492
|
+
// Node's fetch throws "fetch failed" with the real reason hidden in
|
|
493
|
+
// err.cause. Unwrap it + add concrete remediation so the user doesn't
|
|
494
|
+
// have to guess between DNS / proxy / corp SSL / wrong baseUrl / VPN.
|
|
495
|
+
if (/^fetch failed$/i.test(msg) || /ENOTFOUND|ECONNREFUSED|ETIMEDOUT|UNABLE_TO_VERIFY|self.signed|EAI_AGAIN/i.test(msg)) {
|
|
496
|
+
const cause: any = (err as any)?.cause;
|
|
497
|
+
const causeCode = cause?.code || '';
|
|
498
|
+
const causeMsg = cause?.message || '';
|
|
499
|
+
const baseUrl = provider.baseUrl || `(adapter default for ${provider.type})`;
|
|
500
|
+
const hints: string[] = [];
|
|
501
|
+
if (/ENOTFOUND|EAI_AGAIN/i.test(causeCode + causeMsg)) hints.push('DNS — host unresolvable. Corp VPN connected? Hostname typo in baseUrl?');
|
|
502
|
+
if (/ECONNREFUSED/i.test(causeCode + causeMsg)) hints.push('Connection refused — endpoint down, or wrong port.');
|
|
503
|
+
if (/ETIMEDOUT/i.test(causeCode + causeMsg)) hints.push('Timed out — firewall / proxy blocked, or VPN required for this endpoint.');
|
|
504
|
+
if (/UNABLE_TO_VERIFY|self.signed/i.test(causeCode + causeMsg))
|
|
505
|
+
hints.push('TLS cert untrusted — corp SSL inspection (Zscaler / firewall). Set NODE_EXTRA_CA_CERTS=<path/to/corp-ca.pem> in the env when starting forge.');
|
|
506
|
+
if (hints.length === 0) hints.push('Verify: agent baseUrl reachable from this machine + apiKey valid + (if corp network) VPN/proxy/SSL inspection.');
|
|
507
|
+
msg = [
|
|
508
|
+
`LLM fetch failed → ${baseUrl}`,
|
|
509
|
+
causeCode || causeMsg ? `cause: ${causeCode}${causeCode && causeMsg ? ' — ' : ''}${causeMsg}` : '',
|
|
510
|
+
...hints.map(h => '• ' + h),
|
|
511
|
+
'Quick test: curl -v ' + baseUrl + ' (from a terminal in the same shell that started forge)',
|
|
512
|
+
].filter(Boolean).join('\n');
|
|
513
|
+
}
|
|
514
|
+
appendMessage({
|
|
515
|
+
session_id: args.sessionId,
|
|
516
|
+
role: 'assistant',
|
|
517
|
+
blocks: [{ type: 'text', text: '⚠ ' + msg } as TextBlock],
|
|
518
|
+
error: msg,
|
|
519
|
+
});
|
|
520
|
+
cb({ type: 'error', data: { error: msg } });
|
|
521
|
+
return { ok: false, error: msg };
|
|
522
|
+
}
|
|
523
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge client (Forge-internal). Talks to lib/browser-bridge-standalone.ts
|
|
3
|
+
* over loopback HTTP. Used by the chat backend's tool dispatcher to invoke
|
|
4
|
+
* extension-side capabilities (chrome.scripting, chrome.tabs, etc.).
|
|
5
|
+
*
|
|
6
|
+
* The standalone routes each call to a connected extension via its
|
|
7
|
+
* WebSocket. If no extension is paired/connected, this throws — the
|
|
8
|
+
* dispatcher surfaces that as a tool_result with is_error.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const BRIDGE_PORT = Number(process.env.BRIDGE_PORT) || 8407;
|
|
12
|
+
|
|
13
|
+
interface BridgeRpcOk { ok: true; value: unknown }
|
|
14
|
+
interface BridgeRpcErr { ok: false; error: string }
|
|
15
|
+
|
|
16
|
+
export async function bridgeRpc(method: string, params: unknown): Promise<unknown> {
|
|
17
|
+
let res: Response;
|
|
18
|
+
try {
|
|
19
|
+
res = await fetch(`http://127.0.0.1:${BRIDGE_PORT}/api/rpc`, {
|
|
20
|
+
method: 'POST',
|
|
21
|
+
headers: { 'content-type': 'application/json' },
|
|
22
|
+
body: JSON.stringify({ method, params }),
|
|
23
|
+
});
|
|
24
|
+
} catch (e) {
|
|
25
|
+
throw new Error(`browser bridge unreachable on port ${BRIDGE_PORT}: ${(e as Error).message}`);
|
|
26
|
+
}
|
|
27
|
+
if (!res.ok) {
|
|
28
|
+
throw new Error(`bridge http ${res.status}: ${await res.text().catch(() => '')}`);
|
|
29
|
+
}
|
|
30
|
+
const data = (await res.json()) as BridgeRpcOk | BridgeRpcErr;
|
|
31
|
+
if (!data.ok) throw new Error(data.error || 'bridge rpc failed');
|
|
32
|
+
return data.value;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Push an event to all connected extensions (e.g., chat token stream). */
|
|
36
|
+
export async function bridgePush(topic: string, payload: unknown): Promise<{ delivered_to: number }> {
|
|
37
|
+
try {
|
|
38
|
+
const r = await fetch(`http://127.0.0.1:${BRIDGE_PORT}/api/push`, {
|
|
39
|
+
method: 'POST',
|
|
40
|
+
headers: { 'content-type': 'application/json' },
|
|
41
|
+
body: JSON.stringify({ topic, payload }),
|
|
42
|
+
});
|
|
43
|
+
if (!r.ok) return { delivered_to: 0 };
|
|
44
|
+
const j = await r.json();
|
|
45
|
+
return { delivered_to: Number(j.delivered_to ?? 0) };
|
|
46
|
+
} catch {
|
|
47
|
+
return { delivered_to: 0 };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function bridgeStatus(): Promise<{ connected_extensions: number; paired_extensions: number } | null> {
|
|
52
|
+
try {
|
|
53
|
+
const r = await fetch(`http://127.0.0.1:${BRIDGE_PORT}/api/status`);
|
|
54
|
+
if (!r.ok) return null;
|
|
55
|
+
return await r.json();
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|