@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.
Files changed (145) hide show
  1. package/.forge/mcp.json +8 -0
  2. package/.forge/worktrees/pipeline-0a33c50d/lib/help-docs/01-settings.md +5 -5
  3. package/.forge/worktrees/pipeline-0a33c50d/lib/help-docs/07-projects.md +1 -1
  4. package/.forge/worktrees/pipeline-2ba01c10/lib/help-docs/01-settings.md +5 -5
  5. package/.forge/worktrees/pipeline-2ba01c10/lib/help-docs/07-projects.md +1 -1
  6. package/.forge/worktrees/pipeline-3156a8b3/lib/help-docs/01-settings.md +5 -5
  7. package/.forge/worktrees/pipeline-3156a8b3/lib/help-docs/07-projects.md +1 -1
  8. package/.forge/worktrees/pipeline-316c6574/lib/help-docs/01-settings.md +5 -5
  9. package/.forge/worktrees/pipeline-316c6574/lib/help-docs/07-projects.md +1 -1
  10. package/.forge/worktrees/pipeline-44a94121/lib/help-docs/01-settings.md +5 -5
  11. package/.forge/worktrees/pipeline-44a94121/lib/help-docs/07-projects.md +1 -1
  12. package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/01-settings.md +5 -5
  13. package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/07-projects.md +1 -1
  14. package/.forge/worktrees/pipeline-d1757a50/lib/help-docs/01-settings.md +5 -5
  15. package/.forge/worktrees/pipeline-d1757a50/lib/help-docs/07-projects.md +1 -1
  16. package/.forge/worktrees/pipeline-d59c2fe2/lib/help-docs/01-settings.md +5 -5
  17. package/.forge/worktrees/pipeline-d59c2fe2/lib/help-docs/07-projects.md +1 -1
  18. package/.forge/worktrees/pipeline-d6a6ef23/lib/help-docs/01-settings.md +5 -5
  19. package/.forge/worktrees/pipeline-d6a6ef23/lib/help-docs/07-projects.md +1 -1
  20. package/.forge/worktrees/pipeline-e7f78b7a/lib/help-docs/01-settings.md +5 -5
  21. package/.forge/worktrees/pipeline-e7f78b7a/lib/help-docs/07-projects.md +1 -1
  22. package/.forge/worktrees/pipeline-e97c13c7/lib/help-docs/01-settings.md +5 -5
  23. package/.forge/worktrees/pipeline-e97c13c7/lib/help-docs/07-projects.md +1 -1
  24. package/.forge/worktrees/pipeline-ecd7cb0f/lib/help-docs/01-settings.md +5 -5
  25. package/.forge/worktrees/pipeline-ecd7cb0f/lib/help-docs/07-projects.md +1 -1
  26. package/CLAUDE.md +2 -2
  27. package/RELEASE_NOTES.md +101 -5
  28. package/app/api/auth/check/route.ts +18 -0
  29. package/app/api/browser-bridge/route.ts +70 -0
  30. package/app/api/chat/sessions/[id]/events/route.ts +17 -0
  31. package/app/api/chat/sessions/[id]/fork/route.ts +15 -0
  32. package/app/api/chat/sessions/[id]/messages/route.ts +21 -0
  33. package/app/api/chat/sessions/[id]/route.ts +23 -0
  34. package/app/api/chat/sessions/route.ts +12 -0
  35. package/app/api/chat/temper-ping/route.ts +18 -0
  36. package/app/api/chat-proxy/[...path]/route.ts +83 -0
  37. package/app/api/connector-tool/route.ts +38 -0
  38. package/app/api/connectors/[id]/settings/route.ts +112 -0
  39. package/app/api/connectors/route.ts +108 -0
  40. package/app/api/health/tools/route.ts +14 -0
  41. package/app/api/issue-scanner-gitlab/route.ts +95 -0
  42. package/app/api/jobs/[id]/reset_dedup/route.ts +15 -0
  43. package/app/api/jobs/[id]/route.ts +31 -0
  44. package/app/api/jobs/[id]/run/route.ts +44 -0
  45. package/app/api/jobs/[id]/runs/[runId]/route.ts +15 -0
  46. package/app/api/jobs/[id]/runs/route.ts +15 -0
  47. package/app/api/jobs/preview/route.ts +193 -0
  48. package/app/api/jobs/route.ts +36 -0
  49. package/app/api/notify/test/route.ts +39 -7
  50. package/app/api/pipelines/[id]/route.ts +10 -1
  51. package/app/api/pipelines/route.ts +16 -2
  52. package/app/api/plugins/route.ts +40 -8
  53. package/app/api/project-sessions/route.ts +50 -10
  54. package/app/api/settings/route.ts +13 -0
  55. package/app/chat/page.tsx +531 -0
  56. package/bin/forge-server.mjs +3 -1
  57. package/cli/chat.ts +283 -0
  58. package/cli/jobs.ts +176 -0
  59. package/cli/mw.ts +28 -1
  60. package/cli/worktree.ts +245 -0
  61. package/components/ConnectorsPanel.tsx +275 -0
  62. package/components/Dashboard.tsx +90 -37
  63. package/components/JobsView.tsx +361 -0
  64. package/components/LogViewer.tsx +12 -2
  65. package/components/PipelineView.tsx +275 -56
  66. package/components/PluginsPanel.tsx +3 -1
  67. package/components/SettingsModal.tsx +229 -40
  68. package/components/SkillsPanel.tsx +12 -4
  69. package/components/TerminalLauncher.tsx +3 -1
  70. package/components/WebTerminal.tsx +32 -9
  71. package/components/WorkspaceView.tsx +18 -10
  72. package/docs/Connector-DeclarativeExtract-Handoff.md +471 -0
  73. package/docs/Connector-DeclarativeExtract-Spec.md +364 -0
  74. package/docs/Implementation-Plan-Browser-Agent.md +487 -0
  75. package/docs/Jobs-Design.md +240 -0
  76. package/docs/LOCAL-DEPLOY.md +3 -3
  77. package/docs/RFC-Browser-Connectors.md +509 -0
  78. package/lib/agents/index.ts +44 -6
  79. package/lib/agents/types.ts +1 -1
  80. package/lib/browser-bridge-standalone.ts +317 -0
  81. package/lib/builtin-plugins/github-api.yaml +93 -0
  82. package/lib/builtin-plugins/gitlab.yaml +860 -0
  83. package/lib/builtin-plugins/mantis.probe.js +176 -0
  84. package/lib/builtin-plugins/mantis.yaml +964 -0
  85. package/lib/builtin-plugins/pmdb.yaml +178 -0
  86. package/lib/builtin-plugins/teams.yaml +913 -0
  87. package/lib/chat/__test__/smoke.ts +30 -0
  88. package/lib/chat/agent-loop.ts +523 -0
  89. package/lib/chat/bridge-client.ts +59 -0
  90. package/lib/chat/llm/anthropic.ts +99 -0
  91. package/lib/chat/llm/index.ts +20 -0
  92. package/lib/chat/llm/openai.ts +215 -0
  93. package/lib/chat/llm/types.ts +42 -0
  94. package/lib/chat/local-memory.ts +300 -0
  95. package/lib/chat/memory-store.ts +87 -0
  96. package/lib/chat/memory-tools.ts +157 -0
  97. package/lib/chat/protocols/http.ts +118 -0
  98. package/lib/chat/protocols/shell.ts +101 -0
  99. package/lib/chat/proxy.ts +51 -0
  100. package/lib/chat/session-store.ts +272 -0
  101. package/lib/chat/telegram-bridge.ts +276 -0
  102. package/lib/chat/temper.ts +281 -0
  103. package/lib/chat/tool-dispatcher.ts +190 -0
  104. package/lib/chat/types.ts +50 -0
  105. package/lib/chat-standalone.ts +286 -0
  106. package/lib/crypto.ts +1 -1
  107. package/lib/health.ts +131 -0
  108. package/lib/help-docs/00-overview.md +2 -1
  109. package/lib/help-docs/01-settings.md +46 -25
  110. package/lib/help-docs/07-projects.md +1 -1
  111. package/lib/help-docs/10-troubleshooting.md +10 -2
  112. package/lib/help-docs/16-gitlab-autofix.md +114 -0
  113. package/lib/help-docs/17-connectors.md +322 -0
  114. package/lib/help-docs/18-chrome-mcp.md +134 -0
  115. package/lib/help-docs/19-jobs.md +140 -0
  116. package/lib/help-docs/20-mantis-bug-fix.md +115 -0
  117. package/lib/help-docs/CLAUDE.md +10 -0
  118. package/lib/init.ts +137 -50
  119. package/lib/iso-time.ts +30 -0
  120. package/lib/issue-scanner-gitlab.ts +281 -0
  121. package/lib/jobs/dispatcher.ts +217 -0
  122. package/lib/jobs/scheduler.ts +334 -0
  123. package/lib/jobs/store.ts +319 -0
  124. package/lib/jobs/types.ts +117 -0
  125. package/lib/pipeline-scheduler.ts +1 -6
  126. package/lib/pipeline.ts +790 -10
  127. package/lib/plugins/registry.ts +133 -8
  128. package/lib/plugins/templates.ts +83 -0
  129. package/lib/plugins/types.ts +140 -1
  130. package/lib/session-watcher.ts +36 -10
  131. package/lib/settings.ts +65 -33
  132. package/lib/skills.ts +3 -1
  133. package/lib/task-manager.ts +50 -22
  134. package/lib/telegram-bot.ts +71 -0
  135. package/lib/terminal-standalone.ts +58 -36
  136. package/lib/workspace/orchestrator.ts +1 -0
  137. package/middleware.ts +10 -0
  138. package/package.json +3 -2
  139. package/scripts/bench/README.md +1 -1
  140. package/scripts/bench/tasks/01-text-utils/validator.sh +1 -1
  141. package/scripts/bench/tasks/02-pagination/setup.sh +1 -1
  142. package/scripts/bench/tasks/02-pagination/validator.sh +1 -1
  143. package/scripts/bench/tasks/03-bug-fix/setup.sh +1 -1
  144. package/scripts/bench/tasks/03-bug-fix/validator.sh +1 -1
  145. package/src/core/db/database.ts +21 -12
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Temper memory client — used by Forge chat for pinned/recall/episode I/O.
3
+ *
4
+ * Ported from the extension's pre-Phase-4 implementation. Auth: X-API-Key.
5
+ * Default namespace comes from the key — only set the override via
6
+ * settings.temperNamespace if you intentionally want a different scope.
7
+ *
8
+ * The chat agent loop calls listBlocks + search on every turn (silent on
9
+ * failure) and exposes memory_* tools to the LLM for explicit reads /
10
+ * writes. If url or key is missing, the client reports `enabled = false`
11
+ * and every method is a no-op returning the empty/false fallback.
12
+ */
13
+
14
+ export interface SearchHit {
15
+ id: string;
16
+ kind: 'fact' | 'entity' | string;
17
+ fact?: string;
18
+ score?: number;
19
+ valid_at?: string | null;
20
+ invalid_at?: string | null;
21
+ source_node_uuid?: string;
22
+ target_node_uuid?: string;
23
+ namespace?: string;
24
+ }
25
+
26
+ export interface MemoryBlock {
27
+ key: string;
28
+ value: unknown;
29
+ pinned?: boolean;
30
+ priority?: number;
31
+ description?: string;
32
+ scope?: 'own' | 'global' | 'both';
33
+ }
34
+
35
+ interface RawBlock {
36
+ block_key?: string;
37
+ block_value?: unknown;
38
+ key?: string;
39
+ value?: unknown;
40
+ pinned?: boolean;
41
+ priority?: number;
42
+ description?: string;
43
+ scope?: 'own' | 'global' | 'both';
44
+ }
45
+
46
+ function normalizeBlock(raw: RawBlock): MemoryBlock | null {
47
+ const key = raw.block_key ?? raw.key;
48
+ if (!key) return null;
49
+ const value = 'block_value' in raw ? raw.block_value : raw.value;
50
+ return {
51
+ key,
52
+ value,
53
+ pinned: raw.pinned,
54
+ priority: raw.priority,
55
+ description: raw.description,
56
+ scope: raw.scope,
57
+ };
58
+ }
59
+
60
+ export type SourceType = 'message' | 'text' | 'json';
61
+
62
+ export interface EpisodeInput {
63
+ content: string;
64
+ source_type?: SourceType;
65
+ source_description?: string;
66
+ reference_time?: string;
67
+ tags?: string[];
68
+ saga?: string;
69
+ namespace?: string;
70
+ }
71
+
72
+ export class TemperClient {
73
+ constructor(
74
+ private readonly baseUrl: string,
75
+ private readonly apiKey: string = '',
76
+ /** Optional namespace override. Empty = use the key's default. */
77
+ private readonly namespace: string = '',
78
+ ) {}
79
+
80
+ get enabled(): boolean {
81
+ return this.baseUrl.length > 0 && this.apiKey.length > 0;
82
+ }
83
+
84
+ get currentNamespace(): string {
85
+ return this.namespace;
86
+ }
87
+
88
+ private url(path: string): string {
89
+ return this.baseUrl.replace(/\/+$/, '') + path;
90
+ }
91
+
92
+ private headers(extra: Record<string, string> = {}): Record<string, string> {
93
+ return { 'X-API-Key': this.apiKey, ...extra };
94
+ }
95
+
96
+ private withNs(u: URL): URL {
97
+ if (this.namespace) u.searchParams.set('namespace', this.namespace);
98
+ return u;
99
+ }
100
+
101
+ async search(query: string, limit = 8): Promise<SearchHit[]> {
102
+ if (!this.enabled) return [];
103
+ try {
104
+ const u = this.withNs(new URL(this.url('/v1/search')));
105
+ u.searchParams.set('query', query);
106
+ u.searchParams.set('limit', String(limit));
107
+ const r = await fetch(u.toString(), { headers: this.headers() });
108
+ if (!r.ok) return [];
109
+ const j = await r.json();
110
+ if (Array.isArray(j)) return j as SearchHit[];
111
+ if (j && Array.isArray((j as { hits?: SearchHit[] }).hits)) {
112
+ return (j as { hits: SearchHit[] }).hits;
113
+ }
114
+ if (j && Array.isArray((j as { results?: SearchHit[] }).results)) {
115
+ return (j as { results: SearchHit[] }).results;
116
+ }
117
+ return [];
118
+ } catch (err) {
119
+ console.warn('[temper] search failed', err);
120
+ return [];
121
+ }
122
+ }
123
+
124
+ async writeEpisode(ep: EpisodeInput, asyncExtract = true): Promise<boolean> {
125
+ if (!this.enabled) return false;
126
+ try {
127
+ const u = new URL(this.url('/v1/episodes'));
128
+ if (asyncExtract) u.searchParams.set('async_extract', 'true');
129
+ const body: EpisodeInput = {
130
+ source_type: 'text',
131
+ reference_time: new Date().toISOString(),
132
+ ...(this.namespace && !ep.namespace ? { namespace: this.namespace } : {}),
133
+ ...ep,
134
+ };
135
+ const r = await fetch(u.toString(), {
136
+ method: 'POST',
137
+ headers: this.headers({ 'content-type': 'application/json' }),
138
+ body: JSON.stringify(body),
139
+ });
140
+ return r.ok;
141
+ } catch (err) {
142
+ console.warn('[temper] writeEpisode failed', err);
143
+ return false;
144
+ }
145
+ }
146
+
147
+ async listBlocks(opts: { pinned?: boolean; scope?: 'own' | 'global' | 'both' } = {}): Promise<MemoryBlock[]> {
148
+ if (!this.enabled) return [];
149
+ try {
150
+ const u = this.withNs(new URL(this.url('/v1/memory/blocks')));
151
+ if (opts.pinned) u.searchParams.set('pinned', 'true');
152
+ u.searchParams.set('scope', opts.scope ?? 'both');
153
+ const r = await fetch(u.toString(), { headers: this.headers() });
154
+ if (!r.ok) return [];
155
+ const j = await r.json();
156
+ const raws: RawBlock[] = Array.isArray(j)
157
+ ? (j as RawBlock[])
158
+ : j && Array.isArray((j as { blocks?: RawBlock[] }).blocks)
159
+ ? (j as { blocks: RawBlock[] }).blocks
160
+ : [];
161
+ return raws
162
+ .map(normalizeBlock)
163
+ .filter((b): b is MemoryBlock => b !== null);
164
+ } catch (err) {
165
+ console.warn('[temper] listBlocks failed', err);
166
+ return [];
167
+ }
168
+ }
169
+
170
+ async getBlock(key: string, scope: 'own' | 'global' = 'own'): Promise<MemoryBlock | null> {
171
+ if (!this.enabled) return null;
172
+ try {
173
+ const u = this.withNs(new URL(this.url(`/v1/memory/blocks/${encodeURIComponent(key)}`)));
174
+ u.searchParams.set('scope', scope);
175
+ const r = await fetch(u.toString(), { headers: this.headers() });
176
+ if (r.status === 404) return null;
177
+ if (!r.ok) return null;
178
+ const j = (await r.json()) as RawBlock | { block?: RawBlock };
179
+ const raw = 'block' in (j as Record<string, unknown>) ? (j as { block?: RawBlock }).block : (j as RawBlock);
180
+ if (!raw) return null;
181
+ const normalized = normalizeBlock(raw) ?? normalizeBlock({ ...raw, block_key: key });
182
+ return normalized;
183
+ } catch (err) {
184
+ console.warn('[temper] getBlock failed', err);
185
+ return null;
186
+ }
187
+ }
188
+
189
+ async putBlock(
190
+ key: string,
191
+ value: unknown,
192
+ extras: { pinned?: boolean; priority?: number; description?: string; scope?: 'own' | 'global' } = {},
193
+ ): Promise<boolean> {
194
+ if (!this.enabled) return false;
195
+ try {
196
+ const u = this.withNs(new URL(this.url(`/v1/memory/blocks/${encodeURIComponent(key)}`)));
197
+ const r = await fetch(u.toString(), {
198
+ method: 'PUT',
199
+ headers: this.headers({ 'content-type': 'application/json' }),
200
+ body: JSON.stringify({ value, ...extras }),
201
+ });
202
+ return r.ok;
203
+ } catch (err) {
204
+ console.warn('[temper] putBlock failed', err);
205
+ return false;
206
+ }
207
+ }
208
+
209
+ /** Reachability probe — returns ok + pinned count. */
210
+ async ping(): Promise<{ ok: boolean; message: string; pinned: number }> {
211
+ if (!this.baseUrl) return { ok: false, message: 'URL is empty', pinned: 0 };
212
+ if (!this.apiKey) return { ok: false, message: 'API key is empty', pinned: 0 };
213
+ try {
214
+ const u = this.withNs(new URL(this.url('/v1/memory/blocks')));
215
+ u.searchParams.set('pinned', 'true');
216
+ u.searchParams.set('scope', 'both');
217
+ const r = await fetch(u.toString(), { headers: this.headers() });
218
+ if (!r.ok) {
219
+ const text = await r.text().catch(() => '');
220
+ return {
221
+ ok: false,
222
+ message: `${r.status} ${r.statusText}${text ? ': ' + text.slice(0, 200) : ''}`,
223
+ pinned: 0,
224
+ };
225
+ }
226
+ const j = await r.json();
227
+ const arr = Array.isArray(j)
228
+ ? (j as unknown[])
229
+ : j && Array.isArray((j as { blocks?: unknown[] }).blocks)
230
+ ? (j as { blocks: unknown[] }).blocks
231
+ : [];
232
+ const count = arr.length;
233
+ return { ok: true, message: `OK · ${count} pinned block${count === 1 ? '' : 's'}`, pinned: count };
234
+ } catch (err) {
235
+ return { ok: false, message: err instanceof Error ? err.message : String(err), pinned: 0 };
236
+ }
237
+ }
238
+ }
239
+
240
+ // ─── System-prompt rendering ──────────────────────────────
241
+
242
+ function valueShort(v: unknown, max = 200): string {
243
+ const s = typeof v === 'string' ? v : JSON.stringify(v);
244
+ return s.length > max ? s.slice(0, max) + '…' : s;
245
+ }
246
+
247
+ const MAX_INLINED_BLOCKS = 50;
248
+
249
+ /**
250
+ * Inline all known block values into the system prompt. Smaller models
251
+ * won't proactively call memory_get_block from a key listing — they'll
252
+ * just answer what's asked. So we hand them the values directly. The
253
+ * memory_get_block tool is still available for explicit lookups (a
254
+ * value too long to inline, scopes not auto-loaded), but the chat loop
255
+ * does not depend on the LLM choosing to use it.
256
+ */
257
+ export function renderMemoryContext(blocks: MemoryBlock[], hits: SearchHit[]): string {
258
+ const out: string[] = [];
259
+ if (blocks.length > 0) {
260
+ out.push('Known facts about the user (from memory):');
261
+ const inlined = blocks.slice(0, MAX_INLINED_BLOCKS);
262
+ for (const b of inlined) {
263
+ const desc = b.description ? ` (note: ${b.description})` : '';
264
+ out.push(`- ${b.key} = ${valueShort(b.value)}${desc}`);
265
+ }
266
+ if (blocks.length > inlined.length) {
267
+ out.push(`- … and ${blocks.length - inlined.length} more (use memory_list_blocks to enumerate)`);
268
+ }
269
+ out.push('');
270
+ }
271
+
272
+ const factHits = hits.filter((h) => h.fact && h.invalid_at == null);
273
+ if (factHits.length > 0) {
274
+ out.push('Recalled facts (semantic graph search):');
275
+ for (const h of factHits.slice(0, 6)) {
276
+ out.push(`- ${h.fact}`);
277
+ }
278
+ }
279
+
280
+ return out.join('\n');
281
+ }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Tool dispatcher — routes an LLM tool call to the right executor.
3
+ *
4
+ * <plugin_id>.<tool_name> → connector tool
5
+ * browser protocol → dispatched to extension via bridge.connector_run
6
+ * (http/ssh/mcp/etc. → reserved for Phase 3 runtimes)
7
+ * builtin name → in-process function
8
+ *
9
+ * Returns the tool_result block content (string) + is_error flag.
10
+ */
11
+
12
+ import { bridgeRpc } from './bridge-client';
13
+ import { runHttp } from './protocols/http';
14
+ import { runShell } from './protocols/shell';
15
+ import {
16
+ getPlugin,
17
+ getInstalledPlugin,
18
+ getConnectorsForPlugin,
19
+ } from '@/lib/plugins/registry';
20
+ import { expandSettingsTokens } from '@/lib/plugins/templates';
21
+ import type { Connector, ConnectorTool } from '@/lib/plugins/types';
22
+
23
+ export interface ToolCall {
24
+ id: string;
25
+ name: string;
26
+ input: unknown;
27
+ }
28
+
29
+ export interface ToolResult {
30
+ content: string;
31
+ is_error?: boolean;
32
+ }
33
+
34
+ // ─── Builtins ─────────────────────────────────────────────
35
+
36
+ export type BuiltinHandler = (input: unknown) => Promise<string>;
37
+
38
+ const BUILTINS: Record<string, BuiltinHandler> = {
39
+ get_current_time: async () => new Date().toISOString(),
40
+ };
41
+
42
+ export interface BuiltinToolDef {
43
+ name: string;
44
+ description: string;
45
+ input_schema: { type: 'object'; properties: Record<string, unknown>; required?: string[] };
46
+ }
47
+
48
+ export const BUILTIN_TOOL_DEFS: BuiltinToolDef[] = [
49
+ {
50
+ name: 'get_current_time',
51
+ description: 'Get the current local time as an ISO 8601 string. Use whenever the user asks about "now" or "today".',
52
+ input_schema: { type: 'object', properties: {} },
53
+ },
54
+ ];
55
+
56
+ // ─── Connector dispatch ──────────────────────────────────
57
+
58
+ /** Look up a connector tool by `<plugin_id>.<tool_name>` form. */
59
+ function findConnectorTool(qualified: string): {
60
+ pluginId: string;
61
+ toolName: string;
62
+ entry: Connector;
63
+ tool: ConnectorTool;
64
+ settings: Record<string, any>;
65
+ } | null {
66
+ const dot = qualified.indexOf('.');
67
+ if (dot < 1) return null;
68
+ const pluginId = qualified.slice(0, dot);
69
+ const toolName = qualified.slice(dot + 1);
70
+ const def = getPlugin(pluginId);
71
+ if (!def || def.category !== 'connector') return null;
72
+ const entries = getConnectorsForPlugin(def);
73
+ for (const entry of entries) {
74
+ const tool = entry.tools?.[toolName];
75
+ if (tool) {
76
+ const inst = getInstalledPlugin(pluginId);
77
+ return { pluginId, toolName, entry, tool, settings: inst?.config || {} };
78
+ }
79
+ }
80
+ return null;
81
+ }
82
+
83
+ /**
84
+ * Build the ConnectorPayload-ish object the extension's runner expects.
85
+ * Expands {base_url}/{settings.*} tokens here; {args.*} stays literal —
86
+ * the extension's runner finishes those at execution time.
87
+ */
88
+ function buildConnectorPayload(
89
+ def: { id: string; name: string; mode?: string; runner?: 'main' | 'isolated'; host_match?: string; login_redirect?: string },
90
+ entry: Connector,
91
+ settings: Record<string, any>,
92
+ ) {
93
+ const expand = (s: string | undefined) => (s ? expandSettingsTokens(s, settings) : s);
94
+ const hostMatch = expand(entry.host_match || def.host_match);
95
+ const loginRedirect = expand(entry.login_redirect || def.login_redirect);
96
+
97
+ // Build entries with expanded page URLs
98
+ const expandedEntry: any = {
99
+ ...entry,
100
+ host_match: hostMatch,
101
+ login_redirect: loginRedirect,
102
+ tools: Object.fromEntries(
103
+ Object.entries(entry.tools || {}).map(([n, t]: [string, any]) => [
104
+ n,
105
+ {
106
+ ...t,
107
+ page: t.page
108
+ ? {
109
+ url: expand(t.page.url),
110
+ ...(t.page.on_target ? { on_target: expand(t.page.on_target) } : {}),
111
+ ...(t.page.settle_after_load_ms != null ? { settle_after_load_ms: t.page.settle_after_load_ms } : {}),
112
+ }
113
+ : undefined,
114
+ },
115
+ ]),
116
+ ),
117
+ };
118
+
119
+ return {
120
+ plugin_id: def.id,
121
+ name: def.name,
122
+ mode: def.mode || 'browser-side',
123
+ installed: true,
124
+ host_match: hostMatch,
125
+ login_redirect: loginRedirect,
126
+ runner: def.runner || entry.runner || 'main',
127
+ entries: [expandedEntry],
128
+ };
129
+ }
130
+
131
+ // ─── Public entry ─────────────────────────────────────────
132
+
133
+ export async function dispatchTool(
134
+ call: ToolCall,
135
+ /** Per-turn extra builtins (e.g. memory_* when Temper is configured). */
136
+ extraBuiltins?: Record<string, BuiltinHandler>,
137
+ ): Promise<ToolResult> {
138
+ // Per-turn builtins first (memory tools), then global builtins.
139
+ const dynBuiltin = extraBuiltins?.[call.name];
140
+ if (dynBuiltin) {
141
+ try { return { content: await dynBuiltin(call.input) }; }
142
+ catch (e) { return { content: `tool '${call.name}' failed: ${(e as Error).message}`, is_error: true }; }
143
+ }
144
+ const builtin = BUILTINS[call.name];
145
+ if (builtin) {
146
+ try {
147
+ const content = await builtin(call.input);
148
+ return { content };
149
+ } catch (e) {
150
+ return { content: `builtin tool '${call.name}' failed: ${(e as Error).message}`, is_error: true };
151
+ }
152
+ }
153
+
154
+ // Connector tool
155
+ const located = findConnectorTool(call.name);
156
+ if (!located) {
157
+ return { content: `unknown tool: ${call.name}`, is_error: true };
158
+ }
159
+
160
+ const def = getPlugin(located.pluginId)!;
161
+ const protocol = located.tool.protocol || 'browser';
162
+ const argInput = (call.input ?? {}) as Record<string, any>;
163
+
164
+ try {
165
+ switch (protocol) {
166
+ case 'http':
167
+ return await runHttp({ tool: located.tool, settings: located.settings, args: argInput });
168
+ case 'shell':
169
+ return await runShell({ tool: located.tool, settings: located.settings, args: argInput });
170
+ case 'browser': {
171
+ // Hand the whole connector + tool spec + input + settings to the
172
+ // extension's runner.ts via the bridge. The extension keeps owning
173
+ // the runner logic (tab acquire, navigate, executeScript).
174
+ const connector = buildConnectorPayload(def, located.entry, located.settings);
175
+ const result = (await bridgeRpc('connector.run', {
176
+ pluginId: located.pluginId,
177
+ toolName: located.toolName,
178
+ input: argInput,
179
+ connector,
180
+ settings: located.settings,
181
+ })) as { content?: string; is_error?: boolean } | null;
182
+ return { content: result?.content ?? '(no content returned)', is_error: !!result?.is_error };
183
+ }
184
+ default:
185
+ return { content: `unknown protocol "${protocol}" on tool ${call.name}`, is_error: true };
186
+ }
187
+ } catch (e) {
188
+ return { content: `connector tool failed: ${(e as Error).message}`, is_error: true };
189
+ }
190
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Chat types — shared between session store, LLM client, and HTTP API.
3
+ *
4
+ * Block shape mirrors the Anthropic Messages API (text / tool_use /
5
+ * tool_result) so the extension and Forge speak the same dialect.
6
+ */
7
+
8
+ export type Role = 'user' | 'assistant';
9
+
10
+ export interface TextBlock {
11
+ type: 'text';
12
+ text: string;
13
+ }
14
+
15
+ export interface ToolUseBlock {
16
+ type: 'tool_use';
17
+ id: string;
18
+ name: string;
19
+ input: unknown;
20
+ }
21
+
22
+ export interface ToolResultBlock {
23
+ type: 'tool_result';
24
+ tool_use_id: string;
25
+ content: string;
26
+ is_error?: boolean;
27
+ }
28
+
29
+ export type ContentBlock = TextBlock | ToolUseBlock | ToolResultBlock;
30
+
31
+ export interface Message {
32
+ id: string;
33
+ session_id: string;
34
+ role: Role;
35
+ blocks: ContentBlock[];
36
+ ts: number;
37
+ error?: string;
38
+ }
39
+
40
+ export interface Session {
41
+ id: string;
42
+ title: string | null;
43
+ created_at: number;
44
+ updated_at: number;
45
+ model: string | null;
46
+ provider: string | null;
47
+ system_prompt: string | null;
48
+ /** Free-form metadata (e.g. linked workspace, project, etc.) */
49
+ meta?: Record<string, unknown>;
50
+ }