@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,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MemoryStore — common surface for chat's long-term memory.
|
|
3
|
+
*
|
|
4
|
+
* Two implementations:
|
|
5
|
+
* - TemperClient (lib/chat/temper.ts): HTTP-backed, with semantic +
|
|
6
|
+
* graph search via Graphiti.
|
|
7
|
+
* - LocalMemoryStore (lib/chat/local-memory.ts): SQLite-backed
|
|
8
|
+
* fallback when Temper isn't configured. Same block API; search is
|
|
9
|
+
* keyword LIKE over stored block values + episode content.
|
|
10
|
+
*
|
|
11
|
+
* The factory `getMemoryStore()` picks one based on settings:
|
|
12
|
+
* temperUrl + temperKey present → Temper; otherwise → Local.
|
|
13
|
+
*
|
|
14
|
+
* Both report `enabled = true` once they have somewhere to read/write,
|
|
15
|
+
* so the chat loop registers the memory_* tools either way.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { loadSettings } from '@/lib/settings';
|
|
19
|
+
import { TemperClient } from './temper';
|
|
20
|
+
import { LocalMemoryStore } from './local-memory';
|
|
21
|
+
import type { EpisodeInput, MemoryBlock, SearchHit } from './temper';
|
|
22
|
+
|
|
23
|
+
export type { EpisodeInput, MemoryBlock, SearchHit } from './temper';
|
|
24
|
+
|
|
25
|
+
export interface MemoryStore {
|
|
26
|
+
readonly enabled: boolean;
|
|
27
|
+
readonly currentNamespace: string;
|
|
28
|
+
readonly kind: 'temper' | 'local';
|
|
29
|
+
|
|
30
|
+
search(query: string, limit?: number): Promise<SearchHit[]>;
|
|
31
|
+
writeEpisode(ep: EpisodeInput, asyncExtract?: boolean): Promise<boolean>;
|
|
32
|
+
listBlocks(opts?: { pinned?: boolean; scope?: 'own' | 'global' | 'both' }): Promise<MemoryBlock[]>;
|
|
33
|
+
getBlock(key: string, scope?: 'own' | 'global'): Promise<MemoryBlock | null>;
|
|
34
|
+
putBlock(
|
|
35
|
+
key: string,
|
|
36
|
+
value: unknown,
|
|
37
|
+
extras?: { pinned?: boolean; priority?: number; description?: string; scope?: 'own' | 'global' },
|
|
38
|
+
): Promise<boolean>;
|
|
39
|
+
ping(): Promise<{ ok: boolean; message: string; pinned: number }>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Pick the active memory backend.
|
|
44
|
+
*
|
|
45
|
+
* Resolution order:
|
|
46
|
+
* - settings.memoryBackend === 'local' → always LocalMemoryStore.
|
|
47
|
+
* - settings.memoryBackend === 'temper' → always TemperClient (even if
|
|
48
|
+
* credentials are missing — caller will see `enabled = false`).
|
|
49
|
+
* - 'auto' (default): Temper if both URL+key are set, else Local.
|
|
50
|
+
*
|
|
51
|
+
* Callers should NOT cache the result across settings edits — the
|
|
52
|
+
* picker is cheap.
|
|
53
|
+
*/
|
|
54
|
+
export function getMemoryStore(): MemoryStore {
|
|
55
|
+
const s = loadSettings();
|
|
56
|
+
const url = (s.temperUrl || '').trim();
|
|
57
|
+
const key = (s.temperKey || '').trim();
|
|
58
|
+
const mode = s.memoryBackend || 'auto';
|
|
59
|
+
|
|
60
|
+
if (mode === 'local') return new LocalMemoryStore();
|
|
61
|
+
if (mode === 'temper') {
|
|
62
|
+
return makeTemperWrapper(new TemperClient(url, key, s.temperNamespace || ''));
|
|
63
|
+
}
|
|
64
|
+
// auto
|
|
65
|
+
if (url && key) {
|
|
66
|
+
return makeTemperWrapper(new TemperClient(url, key, s.temperNamespace || ''));
|
|
67
|
+
}
|
|
68
|
+
return new LocalMemoryStore();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function makeTemperWrapper(c: TemperClient): MemoryStore {
|
|
72
|
+
return {
|
|
73
|
+
kind: 'temper',
|
|
74
|
+
get enabled() {
|
|
75
|
+
return c.enabled;
|
|
76
|
+
},
|
|
77
|
+
get currentNamespace() {
|
|
78
|
+
return c.currentNamespace;
|
|
79
|
+
},
|
|
80
|
+
search: (q, n) => c.search(q, n),
|
|
81
|
+
writeEpisode: (ep, a) => c.writeEpisode(ep, a),
|
|
82
|
+
listBlocks: (opts) => c.listBlocks(opts ?? {}),
|
|
83
|
+
getBlock: (k, scope) => c.getBlock(k, scope),
|
|
84
|
+
putBlock: (k, v, extras) => c.putBlock(k, v, extras),
|
|
85
|
+
ping: () => c.ping(),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory tools exposed to the LLM by the chat backend.
|
|
3
|
+
*
|
|
4
|
+
* Registered whenever a MemoryStore is enabled — that's always true
|
|
5
|
+
* now: Temper if configured, otherwise the SQLite-backed
|
|
6
|
+
* LocalMemoryStore. The tool schemas and names are identical across
|
|
7
|
+
* backends; only the descriptions stay accurate (e.g. `memory_search`
|
|
8
|
+
* is keyword on local, semantic on Temper).
|
|
9
|
+
*
|
|
10
|
+
* Tool naming uses `memory_*` (no plugin prefix) so they look like
|
|
11
|
+
* first-class builtins next to get_current_time.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { BuiltinToolDef } from './tool-dispatcher';
|
|
15
|
+
import type { MemoryStore } from './memory-store';
|
|
16
|
+
|
|
17
|
+
export type MemoryToolHandler = (input: unknown) => Promise<string>;
|
|
18
|
+
|
|
19
|
+
export interface MemoryTool {
|
|
20
|
+
def: BuiltinToolDef;
|
|
21
|
+
handle: MemoryToolHandler;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function buildMemoryTools(store: MemoryStore): MemoryTool[] {
|
|
25
|
+
if (!store.enabled) return [];
|
|
26
|
+
const isLocal = store.kind === 'local';
|
|
27
|
+
const searchDescription = isLocal
|
|
28
|
+
? 'Keyword search over locally-stored memory blocks and episodes (case-insensitive LIKE). Use for finding past notes / events you wrote earlier. For first-person identity/preferences prefer memory_get_block / memory_list_blocks.'
|
|
29
|
+
: 'Semantic + graph search over Temper episodes (Graphiti). Use for NAMED third-party facts ("what did Alice say about X", "what was decided on the migration"). DO NOT use for first-person questions — pronouns are filtered and you will get 0 hits. For first-person, use memory_get_block / memory_list_blocks.';
|
|
30
|
+
const episodeDescription = isLocal
|
|
31
|
+
? 'Append a short event-shaped fact to the local memory log (one row per call). Searchable via memory_search keyword match. Use for "Alice prefers Postgres", "user decided to ship X on May 20".'
|
|
32
|
+
: 'Write an event-shaped fact to long-term memory (a Temper episode). Use for "Alice prefers Postgres", "user decided to ship X on May 20", external-world facts with subject/object structure. Extraction runs async. ONE discrete fact per call — do NOT dump transcripts.';
|
|
33
|
+
return [
|
|
34
|
+
{
|
|
35
|
+
def: {
|
|
36
|
+
name: 'memory_get_block',
|
|
37
|
+
description:
|
|
38
|
+
'Read ONE memory block by key. Use this for first-person queries ("my name", "what do you call me", "my preferences"). Returns the JSON value or "(not found)".',
|
|
39
|
+
input_schema: {
|
|
40
|
+
type: 'object',
|
|
41
|
+
properties: {
|
|
42
|
+
key: { type: 'string', description: 'Block key, e.g. forge_user_name / preferences.theme / persona.nickname' },
|
|
43
|
+
scope: { type: 'string', enum: ['own', 'global'], description: 'Default: own (falls back to global server-side).' },
|
|
44
|
+
},
|
|
45
|
+
required: ['key'],
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
handle: async (input) => {
|
|
49
|
+
const { key, scope } = input as { key: string; scope?: 'own' | 'global' };
|
|
50
|
+
const b = await store.getBlock(key, scope ?? 'own');
|
|
51
|
+
if (!b) return '(not found)';
|
|
52
|
+
return typeof b.value === 'string' ? b.value : JSON.stringify(b.value);
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
def: {
|
|
57
|
+
name: 'memory_list_blocks',
|
|
58
|
+
description:
|
|
59
|
+
'List memory block keys. Use when the user asks "what do you know about me" / "what have you remembered" or you need to discover what is stored. Returns key + short value preview.',
|
|
60
|
+
input_schema: {
|
|
61
|
+
type: 'object',
|
|
62
|
+
properties: {
|
|
63
|
+
pinned_only: { type: 'boolean', description: 'If true, only pinned (auto-injected) blocks. Default false.' },
|
|
64
|
+
scope: { type: 'string', enum: ['own', 'global', 'both'], description: 'Default: both.' },
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
handle: async (input) => {
|
|
69
|
+
const { pinned_only, scope } = input as {
|
|
70
|
+
pinned_only?: boolean;
|
|
71
|
+
scope?: 'own' | 'global' | 'both';
|
|
72
|
+
};
|
|
73
|
+
const blocks = await store.listBlocks({
|
|
74
|
+
pinned: pinned_only,
|
|
75
|
+
scope: scope ?? 'both',
|
|
76
|
+
});
|
|
77
|
+
if (!blocks.length) return '(no blocks)';
|
|
78
|
+
return blocks
|
|
79
|
+
.map((b) => {
|
|
80
|
+
const v = typeof b.value === 'string' ? b.value : JSON.stringify(b.value);
|
|
81
|
+
const short = v.length > 120 ? v.slice(0, 120) + '…' : v;
|
|
82
|
+
return `- ${b.key}: ${short}`;
|
|
83
|
+
})
|
|
84
|
+
.join('\n');
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
def: {
|
|
89
|
+
name: 'memory_search',
|
|
90
|
+
description: searchDescription,
|
|
91
|
+
input_schema: {
|
|
92
|
+
type: 'object',
|
|
93
|
+
properties: {
|
|
94
|
+
query: { type: 'string', description: 'Free-text query' },
|
|
95
|
+
limit: { type: 'number', description: 'Max hits to return (1-15). Default 6.' },
|
|
96
|
+
},
|
|
97
|
+
required: ['query'],
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
handle: async (input) => {
|
|
101
|
+
const { query, limit } = input as { query: string; limit?: number };
|
|
102
|
+
const hits = await store.search(query, Math.min(15, Math.max(1, limit ?? 6)));
|
|
103
|
+
if (!hits.length) return '(no hits)';
|
|
104
|
+
return hits
|
|
105
|
+
.filter((h) => h.fact && h.invalid_at == null)
|
|
106
|
+
.map((h) => `- ${h.fact}${h.score != null ? ` (score=${h.score.toFixed(2)})` : ''}`)
|
|
107
|
+
.join('\n') || '(no current facts)';
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
def: {
|
|
112
|
+
name: 'memory_remember_block',
|
|
113
|
+
description:
|
|
114
|
+
'Store a first-person fact about the user as a memory block (key/value). Use for preferences, identity, current focus, routines — anything the user asserts about themselves. Block values REPLACE on write. Pin=true to inject into every future system prompt.',
|
|
115
|
+
input_schema: {
|
|
116
|
+
type: 'object',
|
|
117
|
+
properties: {
|
|
118
|
+
key: { type: 'string', description: 'Dotted key, e.g. preferences.language / persona.role / state.current_project' },
|
|
119
|
+
value: { description: 'String or JSON object — the value to store verbatim.' },
|
|
120
|
+
pinned: { type: 'boolean', description: 'If true, auto-injected into every future prompt. Use for stable identity / preferences only.' },
|
|
121
|
+
description: { type: 'string', description: 'Optional human-readable note about why this block exists.' },
|
|
122
|
+
},
|
|
123
|
+
required: ['key', 'value'],
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
handle: async (input) => {
|
|
127
|
+
const { key, value, pinned, description } = input as {
|
|
128
|
+
key: string;
|
|
129
|
+
value: unknown;
|
|
130
|
+
pinned?: boolean;
|
|
131
|
+
description?: string;
|
|
132
|
+
};
|
|
133
|
+
const ok = await store.putBlock(key, value, { pinned, description });
|
|
134
|
+
return ok ? `saved block: ${key}${pinned ? ' (pinned)' : ''}` : 'block save failed';
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
def: {
|
|
139
|
+
name: 'memory_remember_event',
|
|
140
|
+
description: episodeDescription,
|
|
141
|
+
input_schema: {
|
|
142
|
+
type: 'object',
|
|
143
|
+
properties: {
|
|
144
|
+
content: { type: 'string', description: 'Short statement of the fact, with explicit subject (name them, do not say "I" / "me").' },
|
|
145
|
+
tags: { type: 'array', items: { type: 'string' }, description: 'Optional tags like "preference" / "decision".' },
|
|
146
|
+
},
|
|
147
|
+
required: ['content'],
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
handle: async (input) => {
|
|
151
|
+
const { content, tags } = input as { content: string; tags?: string[] };
|
|
152
|
+
const ok = await store.writeEpisode({ content, tags, source_type: 'text' });
|
|
153
|
+
return ok ? 'event recorded' : 'event write failed';
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
];
|
|
157
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP protocol runtime for connector tools.
|
|
3
|
+
*
|
|
4
|
+
* Issues an HTTP request from Forge (server-side, not the user's browser).
|
|
5
|
+
* Used by tools where the upstream offers a clean REST API and no DOM
|
|
6
|
+
* scraping is needed (GitHub API, GitLab API, internal services).
|
|
7
|
+
*
|
|
8
|
+
* Templates: {base_url}, {settings.*}, {args.*} are expanded into url,
|
|
9
|
+
* query values, header values, and body. Body can be a string or an
|
|
10
|
+
* object — strings are sent as-is after expansion; objects are walked
|
|
11
|
+
* and any string leaves are expanded, then JSON.stringify'd.
|
|
12
|
+
*
|
|
13
|
+
* Response: the body is returned verbatim (truncated to 8 KB for the
|
|
14
|
+
* LLM context) with a status-line preamble. Non-2xx is surfaced as
|
|
15
|
+
* is_error so the LLM can react.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { HttpRequestSpec, ConnectorTool } from '@/lib/plugins/types';
|
|
19
|
+
import { expandAllTokens } from '@/lib/plugins/templates';
|
|
20
|
+
|
|
21
|
+
export interface HttpProtocolArgs {
|
|
22
|
+
tool: ConnectorTool;
|
|
23
|
+
settings: Record<string, any>;
|
|
24
|
+
args: Record<string, any>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface HttpProtocolResult {
|
|
28
|
+
content: string;
|
|
29
|
+
is_error?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
33
|
+
const MAX_TIMEOUT_MS = 300_000;
|
|
34
|
+
const MAX_BODY_BYTES = 8 * 1024;
|
|
35
|
+
|
|
36
|
+
function expandObjectLeaves(obj: any, settings: Record<string, any>, args: Record<string, any>): any {
|
|
37
|
+
if (obj == null) return obj;
|
|
38
|
+
if (typeof obj === 'string') return expandAllTokens(obj, settings, args);
|
|
39
|
+
if (Array.isArray(obj)) return obj.map((v) => expandObjectLeaves(v, settings, args));
|
|
40
|
+
if (typeof obj === 'object') {
|
|
41
|
+
const out: Record<string, any> = {};
|
|
42
|
+
for (const [k, v] of Object.entries(obj)) out[k] = expandObjectLeaves(v, settings, args);
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
return obj;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function buildUrl(spec: HttpRequestSpec, settings: Record<string, any>, args: Record<string, any>): string {
|
|
49
|
+
const base = expandAllTokens(spec.url, settings, args);
|
|
50
|
+
if (!spec.query) return base;
|
|
51
|
+
const url = new URL(base);
|
|
52
|
+
for (const [k, raw] of Object.entries(spec.query)) {
|
|
53
|
+
const v = expandAllTokens(String(raw), settings, args);
|
|
54
|
+
url.searchParams.append(k, v);
|
|
55
|
+
}
|
|
56
|
+
return url.toString();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function buildHeaders(spec: HttpRequestSpec, settings: Record<string, any>, args: Record<string, any>): Headers {
|
|
60
|
+
const h = new Headers();
|
|
61
|
+
if (spec.headers) {
|
|
62
|
+
for (const [k, raw] of Object.entries(spec.headers)) {
|
|
63
|
+
h.set(k, expandAllTokens(String(raw), settings, args));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return h;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function buildBody(spec: HttpRequestSpec, settings: Record<string, any>, args: Record<string, any>): { body?: string; contentType?: string } {
|
|
70
|
+
if (spec.body == null) return {};
|
|
71
|
+
if (typeof spec.body === 'string') {
|
|
72
|
+
return { body: expandAllTokens(spec.body, settings, args) };
|
|
73
|
+
}
|
|
74
|
+
const obj = expandObjectLeaves(spec.body, settings, args);
|
|
75
|
+
return { body: JSON.stringify(obj), contentType: 'application/json' };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function truncate(s: string): { text: string; truncated: boolean; totalBytes: number } {
|
|
79
|
+
const buf = Buffer.from(s, 'utf-8');
|
|
80
|
+
if (buf.byteLength <= MAX_BODY_BYTES) return { text: s, truncated: false, totalBytes: buf.byteLength };
|
|
81
|
+
const slice = buf.subarray(0, MAX_BODY_BYTES).toString('utf-8');
|
|
82
|
+
return { text: slice, truncated: true, totalBytes: buf.byteLength };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function runHttp({ tool, settings, args }: HttpProtocolArgs): Promise<HttpProtocolResult> {
|
|
86
|
+
const spec = tool.request;
|
|
87
|
+
if (!spec || !spec.url) {
|
|
88
|
+
return { content: 'http tool missing `request.url`', is_error: true };
|
|
89
|
+
}
|
|
90
|
+
const method = (spec.method || 'GET').toUpperCase();
|
|
91
|
+
const timeoutMs = Math.min(MAX_TIMEOUT_MS, Math.max(1, Number(tool.timeout_ms || DEFAULT_TIMEOUT_MS)));
|
|
92
|
+
|
|
93
|
+
const url = buildUrl(spec, settings, args);
|
|
94
|
+
const headers = buildHeaders(spec, settings, args);
|
|
95
|
+
const { body, contentType } = buildBody(spec, settings, args);
|
|
96
|
+
if (body != null && contentType && !headers.has('content-type')) headers.set('content-type', contentType);
|
|
97
|
+
|
|
98
|
+
const controller = new AbortController();
|
|
99
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
100
|
+
|
|
101
|
+
let res: Response;
|
|
102
|
+
try {
|
|
103
|
+
res = await fetch(url, { method, headers, body, signal: controller.signal });
|
|
104
|
+
} catch (e) {
|
|
105
|
+
clearTimeout(timer);
|
|
106
|
+
return { content: `http request failed: ${(e as Error).message}`, is_error: true };
|
|
107
|
+
}
|
|
108
|
+
clearTimeout(timer);
|
|
109
|
+
|
|
110
|
+
const text = await res.text().catch(() => '');
|
|
111
|
+
const { text: shown, truncated, totalBytes } = truncate(text);
|
|
112
|
+
const preamble = `HTTP ${res.status} ${res.statusText} · ${method} ${url}\n` +
|
|
113
|
+
(truncated ? `(showing ${MAX_BODY_BYTES} of ${totalBytes} bytes — truncated)\n\n` : '\n');
|
|
114
|
+
return {
|
|
115
|
+
content: preamble + shown,
|
|
116
|
+
is_error: !res.ok,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shell protocol runtime for connector tools.
|
|
3
|
+
*
|
|
4
|
+
* Spawns a process with an explicit arg array (no shell:true) so
|
|
5
|
+
* templated values can never inject shell metacharacters. Each arg
|
|
6
|
+
* is templated independently — e.g. `["git", "-C", "{args.repo}",
|
|
7
|
+
* "log", "-n", "{args.n}"]` produces a literal arg, not a shell line.
|
|
8
|
+
*
|
|
9
|
+
* Returns combined output. Non-zero exit code is surfaced as is_error
|
|
10
|
+
* with the exit code in the preamble and stderr if any.
|
|
11
|
+
*
|
|
12
|
+
* Safety boundary: connectors are user-installed manifests. A malicious
|
|
13
|
+
* yaml could still pick a destructive `command[0]` (rm, etc.), so
|
|
14
|
+
* shell-protocol tools must be reviewed at install time. There's no
|
|
15
|
+
* automatic command allow-list in v1.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { ConnectorTool } from '@/lib/plugins/types';
|
|
19
|
+
import { expandAllTokens } from '@/lib/plugins/templates';
|
|
20
|
+
import { spawn } from 'node:child_process';
|
|
21
|
+
|
|
22
|
+
export interface ShellProtocolArgs {
|
|
23
|
+
tool: ConnectorTool;
|
|
24
|
+
settings: Record<string, any>;
|
|
25
|
+
args: Record<string, any>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ShellProtocolResult {
|
|
29
|
+
content: string;
|
|
30
|
+
is_error?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
34
|
+
const MAX_TIMEOUT_MS = 300_000;
|
|
35
|
+
const MAX_OUTPUT_BYTES = 8 * 1024;
|
|
36
|
+
|
|
37
|
+
function truncate(s: string): string {
|
|
38
|
+
const buf = Buffer.from(s, 'utf-8');
|
|
39
|
+
if (buf.byteLength <= MAX_OUTPUT_BYTES) return s;
|
|
40
|
+
return buf.subarray(0, MAX_OUTPUT_BYTES).toString('utf-8') +
|
|
41
|
+
`\n(...truncated, total ${buf.byteLength} bytes)`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function runShell({ tool, settings, args }: ShellProtocolArgs): Promise<ShellProtocolResult> {
|
|
45
|
+
const cmd = tool.command;
|
|
46
|
+
if (!cmd || !Array.isArray(cmd) || cmd.length === 0) {
|
|
47
|
+
return { content: 'shell tool missing `command` array', is_error: true };
|
|
48
|
+
}
|
|
49
|
+
const expanded = cmd.map((part) => expandAllTokens(String(part), settings, args));
|
|
50
|
+
const [bin, ...argv] = expanded;
|
|
51
|
+
const cwd = tool.cwd ? expandAllTokens(tool.cwd, settings, args) : undefined;
|
|
52
|
+
const env = tool.env
|
|
53
|
+
? Object.fromEntries(Object.entries(tool.env).map(([k, v]) => [k, expandAllTokens(String(v), settings, args)]))
|
|
54
|
+
: undefined;
|
|
55
|
+
const timeoutMs = Math.min(MAX_TIMEOUT_MS, Math.max(1, Number(tool.timeout_ms || DEFAULT_TIMEOUT_MS)));
|
|
56
|
+
|
|
57
|
+
return new Promise<ShellProtocolResult>((resolve) => {
|
|
58
|
+
let proc;
|
|
59
|
+
try {
|
|
60
|
+
proc = spawn(bin!, argv, {
|
|
61
|
+
cwd,
|
|
62
|
+
env: env ? { ...process.env, ...env } : process.env,
|
|
63
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
64
|
+
});
|
|
65
|
+
} catch (e) {
|
|
66
|
+
return resolve({ content: `shell spawn failed: ${(e as Error).message}`, is_error: true });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let stdout = '';
|
|
70
|
+
let stderr = '';
|
|
71
|
+
let killed = false;
|
|
72
|
+
const timer = setTimeout(() => {
|
|
73
|
+
killed = true;
|
|
74
|
+
try { proc.kill('SIGTERM'); } catch {}
|
|
75
|
+
}, timeoutMs);
|
|
76
|
+
|
|
77
|
+
proc.stdout?.on('data', (b) => { stdout += b.toString('utf-8'); });
|
|
78
|
+
proc.stderr?.on('data', (b) => { stderr += b.toString('utf-8'); });
|
|
79
|
+
|
|
80
|
+
proc.on('error', (err) => {
|
|
81
|
+
clearTimeout(timer);
|
|
82
|
+
resolve({ content: `shell error: ${err.message}`, is_error: true });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
proc.on('close', (code, signal) => {
|
|
86
|
+
clearTimeout(timer);
|
|
87
|
+
const preamble = killed
|
|
88
|
+
? `[killed after ${timeoutMs}ms]\n`
|
|
89
|
+
: signal
|
|
90
|
+
? `[signal ${signal}]\n`
|
|
91
|
+
: code === 0
|
|
92
|
+
? ''
|
|
93
|
+
: `[exit ${code}]\n`;
|
|
94
|
+
const trail = stderr.trim() && code !== 0 ? `\n--- stderr ---\n${truncate(stderr)}` : '';
|
|
95
|
+
resolve({
|
|
96
|
+
content: preamble + truncate(stdout) + trail,
|
|
97
|
+
is_error: killed || code !== 0,
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin proxy helper — forwards a Next.js request to chat-standalone
|
|
3
|
+
* (127.0.0.1:$CHAT_PORT, default 8408). Auth is enforced by the Next
|
|
4
|
+
* middleware before we get here; chat-standalone itself is loopback-only.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const CHAT_PORT = Number(process.env.CHAT_PORT) || 8408;
|
|
8
|
+
|
|
9
|
+
export async function proxyToChat(req: Request, path: string): Promise<Response> {
|
|
10
|
+
const url = new URL(req.url);
|
|
11
|
+
const target = `http://127.0.0.1:${CHAT_PORT}${path}${url.search || ''}`;
|
|
12
|
+
|
|
13
|
+
// Pass the body through verbatim for POST/PATCH. GET/DELETE go without.
|
|
14
|
+
const init: RequestInit = {
|
|
15
|
+
method: req.method,
|
|
16
|
+
headers: { 'content-type': 'application/json' },
|
|
17
|
+
body: req.method === 'GET' || req.method === 'DELETE' || req.method === 'HEAD'
|
|
18
|
+
? undefined
|
|
19
|
+
: await req.text(),
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
let upstream: Response;
|
|
23
|
+
try {
|
|
24
|
+
upstream = await fetch(target, init);
|
|
25
|
+
} catch (e) {
|
|
26
|
+
return new Response(JSON.stringify({ error: `chat service unreachable on ${CHAT_PORT}: ${(e as Error).message}` }), {
|
|
27
|
+
status: 502, headers: { 'content-type': 'application/json' },
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// For SSE streams, pipe the body through as a streaming response so
|
|
32
|
+
// events reach the browser as they arrive.
|
|
33
|
+
const ct = upstream.headers.get('content-type') || '';
|
|
34
|
+
if (ct.startsWith('text/event-stream') && upstream.body) {
|
|
35
|
+
return new Response(upstream.body, {
|
|
36
|
+
status: upstream.status,
|
|
37
|
+
headers: {
|
|
38
|
+
'content-type': 'text/event-stream',
|
|
39
|
+
'cache-control': 'no-cache, no-transform',
|
|
40
|
+
'connection': 'keep-alive',
|
|
41
|
+
'x-accel-buffering': 'no',
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const body = await upstream.text();
|
|
47
|
+
return new Response(body, {
|
|
48
|
+
status: upstream.status,
|
|
49
|
+
headers: { 'content-type': upstream.headers.get('content-type') || 'application/json' },
|
|
50
|
+
});
|
|
51
|
+
}
|