@aion0/forge 0.9.16 → 0.9.19
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/RELEASE_NOTES.md +12 -8
- package/app/api/connectors/[id]/settings/route.ts +68 -10
- package/app/api/connectors/[id]/test/route.ts +28 -5
- package/app/api/memory/blocks/route.ts +56 -0
- package/app/api/monitor/route.ts +2 -0
- package/app/chat/page.tsx +189 -2
- package/bin/forge-server.mjs +3 -2
- package/components/ConnectorsPanel.tsx +141 -1
- package/components/MonitorPanel.tsx +2 -0
- package/lib/chat/agent-loop.ts +39 -8
- package/lib/chat/build-memory-context.ts +91 -0
- package/lib/chat/llm/openai.ts +4 -1
- package/lib/chat/local-memory.ts +22 -5
- package/lib/chat/protocols/http.ts +198 -24
- package/lib/chat/session-store.ts +49 -0
- package/lib/chat/tool-dispatcher.ts +84 -7
- package/lib/chat-standalone.ts +6 -0
- package/lib/connectors/registry.ts +76 -18
- package/lib/connectors/types.ts +87 -1
- package/lib/help-docs/21-build-connector.md +139 -0
- package/lib/init.ts +16 -0
- package/lib/memory/compress-messages.ts +65 -0
- package/lib/memory/keys.ts +82 -0
- package/lib/memory/temper-summary.ts +485 -0
- package/lib/memory/token-estimate.ts +28 -0
- package/lib/memory-standalone.ts +108 -0
- package/package.json +1 -1
- package/scripts/test-memory-local.ts +139 -0
- package/scripts/test-memory-upsert.ts +106 -0
|
@@ -48,8 +48,8 @@ const BUILTINS: Record<string, BuiltinHandler> = {
|
|
|
48
48
|
// required (no default) vs optional (have default) so the agent can omit
|
|
49
49
|
// optional ones rather than passing wrong placeholder values.
|
|
50
50
|
trigger_pipeline: async (input) => {
|
|
51
|
-
const params = (input as { workflow?: string; input?: Record<string, unknown
|
|
52
|
-
const { listWorkflows, startPipeline, getPipeline } = await import('../pipeline');
|
|
51
|
+
const params = (input as { workflow?: string; input?: Record<string, unknown>; skills?: unknown } | undefined) || {};
|
|
52
|
+
const { listWorkflows, startPipeline, getPipeline, getWorkflow } = await import('../pipeline');
|
|
53
53
|
if (!params.workflow) {
|
|
54
54
|
const workflows = listWorkflows();
|
|
55
55
|
if (workflows.length === 0) return 'No workflows found. Create one in <dataDir>/flows/.';
|
|
@@ -120,7 +120,48 @@ const BUILTINS: Record<string, BuiltinHandler> = {
|
|
|
120
120
|
for (const [k, v] of Object.entries(params.input || {})) {
|
|
121
121
|
stringInput[k] = v == null ? '' : typeof v === 'string' ? v : String(v);
|
|
122
122
|
}
|
|
123
|
-
|
|
123
|
+
|
|
124
|
+
// Skills — same plumbing as Schedule / Job: pre-install each named
|
|
125
|
+
// skill into every project the workflow's nodes target, then thread
|
|
126
|
+
// the list through to startPipeline so per-task --append-system-prompt
|
|
127
|
+
// gets the /skill-name lines. Unknown skill names are warned (not
|
|
128
|
+
// blocked) — startPipeline ignores any that aren't installed by then.
|
|
129
|
+
let skills: string[] = [];
|
|
130
|
+
if (Array.isArray(params.skills)) {
|
|
131
|
+
skills = (params.skills as unknown[]).filter((s): s is string => typeof s === 'string' && s.trim() !== '');
|
|
132
|
+
}
|
|
133
|
+
if (skills.length > 0) {
|
|
134
|
+
try {
|
|
135
|
+
const { listSkills } = await import('../skills');
|
|
136
|
+
const known = new Set(listSkills().map((s: any) => s.name));
|
|
137
|
+
const unknown = skills.filter((s) => !known.has(s));
|
|
138
|
+
if (unknown.length > 0) {
|
|
139
|
+
return `Unknown skills: ${unknown.join(', ')}. Available: ${[...known].slice(0, 40).join(', ')}. Call list_forge_context to see all skills.`;
|
|
140
|
+
}
|
|
141
|
+
const { ensureInstalledInProject } = await import('../skills');
|
|
142
|
+
const { getProjectInfo } = await import('../projects');
|
|
143
|
+
const projectNames = new Set<string>();
|
|
144
|
+
for (const n of Object.values(wf.nodes || {})) {
|
|
145
|
+
if ((n as any).project) projectNames.add((n as any).project as string);
|
|
146
|
+
}
|
|
147
|
+
const seenPaths = new Set<string>();
|
|
148
|
+
for (const pName of projectNames) {
|
|
149
|
+
const resolved = pName.replace(/\{\{\s*input\.([\w-]+)\s*\}\}/g, (_, k: string) => stringInput[k] ?? '');
|
|
150
|
+
if (!resolved) continue;
|
|
151
|
+
const pInfo = getProjectInfo(resolved);
|
|
152
|
+
if (!pInfo || seenPaths.has(pInfo.path)) continue;
|
|
153
|
+
seenPaths.add(pInfo.path);
|
|
154
|
+
for (const skill of skills) {
|
|
155
|
+
try { await ensureInstalledInProject(skill, pInfo.path); }
|
|
156
|
+
catch (e) { console.warn(`[chat] skill "${skill}" install failed in ${pInfo.path}: ${(e as Error).message}`); }
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
} catch (e) {
|
|
160
|
+
console.warn(`[chat] skills preflight failed: ${(e as Error).message}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const pipeline = startPipeline(params.workflow, stringInput, { skills: skills.length ? skills : undefined });
|
|
124
165
|
let line = `Pipeline started: ${pipeline.id} (workflow: ${params.workflow}, status: ${pipeline.status})`;
|
|
125
166
|
if (pipeline.status === 'failed') {
|
|
126
167
|
const fresh = getPipeline(pipeline.id) || pipeline;
|
|
@@ -242,6 +283,11 @@ export const BUILTIN_TOOL_DEFS: BuiltinToolDef[] = [
|
|
|
242
283
|
type: 'object',
|
|
243
284
|
description: 'Pipeline input fields as a flat object. Pass ONLY required fields (marked * in the list response) and optional fields the user explicitly named. Omit optional fields to use their defaults.',
|
|
244
285
|
},
|
|
286
|
+
skills: {
|
|
287
|
+
type: 'array',
|
|
288
|
+
items: { type: 'string' },
|
|
289
|
+
description: 'Forge skills (by name) to make available to every Claude task inside the pipeline — injected via --append-system-prompt. Pass when the user mentions skill names ("用 git-savvy", "with the code-reviewer skill"). Call list_forge_context to validate names. Omit if the user didn\'t mention any.',
|
|
290
|
+
},
|
|
245
291
|
},
|
|
246
292
|
},
|
|
247
293
|
},
|
|
@@ -400,23 +446,54 @@ export async function dispatchTool(
|
|
|
400
446
|
const protocol = located.tool.protocol || 'browser';
|
|
401
447
|
const argInput = (call.input ?? {}) as Record<string, any>;
|
|
402
448
|
|
|
449
|
+
// Multi-instance overlay: when a connector's settings carry a
|
|
450
|
+
// `instances` array of `{name, ...}` objects, the tool's `instance`
|
|
451
|
+
// arg picks one and its fields are merged into the top-level settings
|
|
452
|
+
// namespace so templates like `{settings.base_url}` resolve against
|
|
453
|
+
// the chosen instance. Strictly guarded so connectors without this
|
|
454
|
+
// shape (the existing gitlab/mantis/teams/github-api/pmdb) are
|
|
455
|
+
// completely unaffected.
|
|
456
|
+
let effectiveSettings = located.settings;
|
|
457
|
+
let instances = (located.settings as any)?.instances;
|
|
458
|
+
// The `type: json` settings UI stores the field as a string literal,
|
|
459
|
+
// not a parsed value — accept both forms (string from the form,
|
|
460
|
+
// already-parsed array from manifests that ship a default).
|
|
461
|
+
if (typeof instances === 'string') {
|
|
462
|
+
try { instances = JSON.parse(instances); } catch { instances = null; }
|
|
463
|
+
}
|
|
464
|
+
if (
|
|
465
|
+
Array.isArray(instances) &&
|
|
466
|
+
instances.length > 0 &&
|
|
467
|
+
instances.every((i: any) => i && typeof i === 'object' && typeof i.name === 'string')
|
|
468
|
+
) {
|
|
469
|
+
const wanted = typeof argInput.instance === 'string' ? argInput.instance : undefined;
|
|
470
|
+
const inst = wanted
|
|
471
|
+
? instances.find((i: any) => i.name === wanted)
|
|
472
|
+
: instances[0];
|
|
473
|
+
if (wanted && !inst) {
|
|
474
|
+
const available = instances.map((i: any) => i.name).join(', ');
|
|
475
|
+
return { content: `unknown instance "${wanted}". Available: ${available}`, is_error: true };
|
|
476
|
+
}
|
|
477
|
+
effectiveSettings = { ...located.settings, ...inst };
|
|
478
|
+
}
|
|
479
|
+
|
|
403
480
|
try {
|
|
404
481
|
switch (protocol) {
|
|
405
482
|
case 'http':
|
|
406
|
-
return await runHttp({ tool: located.tool, settings:
|
|
483
|
+
return await runHttp({ tool: located.tool, settings: effectiveSettings, args: argInput, connectorAuth: def.auth, noTruncation: opts.noTruncation });
|
|
407
484
|
case 'shell':
|
|
408
|
-
return await runShell({ tool: located.tool, settings:
|
|
485
|
+
return await runShell({ tool: located.tool, settings: effectiveSettings, args: argInput });
|
|
409
486
|
case 'browser': {
|
|
410
487
|
// Hand the whole connector + tool spec + input + settings to the
|
|
411
488
|
// extension's runner.ts via the bridge. The extension keeps owning
|
|
412
489
|
// the runner logic (tab acquire, navigate, executeScript).
|
|
413
|
-
const connector = buildConnectorPayload(def, located.entry,
|
|
490
|
+
const connector = buildConnectorPayload(def, located.entry, effectiveSettings);
|
|
414
491
|
const result = (await bridgeRpc('connector.run', {
|
|
415
492
|
pluginId: located.connectorId, // wire-name kept for extension
|
|
416
493
|
toolName: located.toolName,
|
|
417
494
|
input: argInput,
|
|
418
495
|
connector,
|
|
419
|
-
settings:
|
|
496
|
+
settings: effectiveSettings,
|
|
420
497
|
})) as { content?: string; is_error?: boolean } | null;
|
|
421
498
|
return { content: result?.content ?? '(no content returned)', is_error: !!result?.is_error };
|
|
422
499
|
}
|
package/lib/chat-standalone.ts
CHANGED
|
@@ -144,6 +144,12 @@ async function handleSessionDelete(_req: IncomingMessage, res: ServerResponse, i
|
|
|
144
144
|
}
|
|
145
145
|
|
|
146
146
|
async function handleSessionClearMessages(_req: IncomingMessage, res: ServerResponse, id: string): Promise<void> {
|
|
147
|
+
// Intent: "Clear chat" only drops chat_messages rows. memory_store
|
|
148
|
+
// blocks (cursor / health / summary / facts) stay — once a fact has
|
|
149
|
+
// been extracted into long-term memory it should survive clearing
|
|
150
|
+
// the conversation it came from. Users can delete memory explicitly
|
|
151
|
+
// from the memory tab if they really want to forget. See
|
|
152
|
+
// forge-chat-memory-summarizer-design.md §11 decision 3.
|
|
147
153
|
const session = getSession(id);
|
|
148
154
|
if (!session) return sendJson(res, 404, { error: 'session not found' });
|
|
149
155
|
const removed = clearSessionMessages(id);
|
|
@@ -132,6 +132,70 @@ function getSecretFieldNames(def: ConnectorDefinition | null): string[] {
|
|
|
132
132
|
.map(([name]) => name);
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
function getAllSettingsSchema(def: ConnectorDefinition | null): Record<string, ConnectorFieldSchema> {
|
|
136
|
+
const out: Record<string, ConnectorFieldSchema> = {};
|
|
137
|
+
if (!def) return out;
|
|
138
|
+
if (def.settings) Object.assign(out, def.settings);
|
|
139
|
+
for (const entry of def.connectors || []) {
|
|
140
|
+
if (entry.settings) Object.assign(out, entry.settings);
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Walk a connector config value applying `op` (encrypt | decrypt) to
|
|
147
|
+
* every leaf whose schema is `type: 'secret'` or `'password'`. Recurses
|
|
148
|
+
* into `type: 'instances'` arrays so per-row sub-secrets (e.g. Jenkins
|
|
149
|
+
* api_token inside each instances row) get the same treatment as flat
|
|
150
|
+
* top-level secrets — otherwise nested credentials stay plaintext at
|
|
151
|
+
* rest. The on-disk shape is preserved: instances stay as a JSON-
|
|
152
|
+
* stringified array (which is how the type:instances UI persists it).
|
|
153
|
+
*/
|
|
154
|
+
function transformConnectorSecrets(
|
|
155
|
+
value: any,
|
|
156
|
+
schema: ConnectorFieldSchema | undefined,
|
|
157
|
+
op: 'encrypt' | 'decrypt',
|
|
158
|
+
): any {
|
|
159
|
+
if (!schema) return value;
|
|
160
|
+
const t = String((schema as any)?.type || '');
|
|
161
|
+
|
|
162
|
+
if (t === 'secret' || t === 'password') {
|
|
163
|
+
if (typeof value !== 'string' || !value) return value;
|
|
164
|
+
if (op === 'encrypt') return isEncrypted(value) ? value : (() => {
|
|
165
|
+
try { return encryptSecret(value); } catch { return value; }
|
|
166
|
+
})();
|
|
167
|
+
if (op === 'decrypt') {
|
|
168
|
+
if (!isEncrypted(value)) return value;
|
|
169
|
+
try { return decryptSecret(value); }
|
|
170
|
+
catch { return value; }
|
|
171
|
+
}
|
|
172
|
+
return value;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (t === 'instances' && (schema as any).fields) {
|
|
176
|
+
// Accept either the stored JSON-string form or an in-memory array.
|
|
177
|
+
let rows: any;
|
|
178
|
+
const wasString = typeof value === 'string';
|
|
179
|
+
if (wasString) {
|
|
180
|
+
try { rows = JSON.parse(value); } catch { return value; }
|
|
181
|
+
} else {
|
|
182
|
+
rows = value;
|
|
183
|
+
}
|
|
184
|
+
if (!Array.isArray(rows)) return value;
|
|
185
|
+
const transformed = rows.map((row: any) => {
|
|
186
|
+
if (!row || typeof row !== 'object') return row;
|
|
187
|
+
const out: any = { ...row };
|
|
188
|
+
for (const [k, sub] of Object.entries((schema as any).fields)) {
|
|
189
|
+
out[k] = transformConnectorSecrets(out[k], sub as ConnectorFieldSchema, op);
|
|
190
|
+
}
|
|
191
|
+
return out;
|
|
192
|
+
});
|
|
193
|
+
return wasString ? JSON.stringify(transformed) : transformed;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return value;
|
|
197
|
+
}
|
|
198
|
+
|
|
135
199
|
function loadStoreRaw(): ConfigStore {
|
|
136
200
|
const p = configsFile();
|
|
137
201
|
if (!existsSync(p)) return {};
|
|
@@ -144,20 +208,16 @@ function loadStoreRaw(): ConfigStore {
|
|
|
144
208
|
|
|
145
209
|
function loadStore(): ConfigStore {
|
|
146
210
|
const store = loadStoreRaw();
|
|
147
|
-
// Decrypt secret fields lazily — readers expect plaintext.
|
|
211
|
+
// Decrypt secret fields lazily — readers expect plaintext. Walks the
|
|
212
|
+
// schema so both flat top-level secrets AND nested instances sub-
|
|
213
|
+
// secrets get unwrapped uniformly.
|
|
148
214
|
for (const [id, row] of Object.entries(store)) {
|
|
149
215
|
if (!row?.config) continue;
|
|
150
216
|
const def = getConnector(id);
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
if (typeof v === 'string' && isEncrypted(v)) {
|
|
156
|
-
try { row.config[key] = decryptSecret(v); }
|
|
157
|
-
catch (err) {
|
|
158
|
-
console.warn(`[connectors] failed to decrypt ${id}.${key}`, err);
|
|
159
|
-
}
|
|
160
|
-
}
|
|
217
|
+
const schema = getAllSettingsSchema(def);
|
|
218
|
+
for (const [k, sub] of Object.entries(schema)) {
|
|
219
|
+
try { row.config[k] = transformConnectorSecrets(row.config[k], sub, 'decrypt'); }
|
|
220
|
+
catch (err) { console.warn(`[connectors] failed to decrypt ${id}.${k}`, err); }
|
|
161
221
|
}
|
|
162
222
|
}
|
|
163
223
|
return store;
|
|
@@ -169,15 +229,13 @@ function saveStore(store: ConfigStore): void {
|
|
|
169
229
|
const out: ConfigStore = {};
|
|
170
230
|
for (const [id, row] of Object.entries(store)) {
|
|
171
231
|
const def = getConnector(id);
|
|
172
|
-
const
|
|
232
|
+
const schema = getAllSettingsSchema(def);
|
|
173
233
|
const encryptedConfig: Record<string, unknown> = {};
|
|
174
234
|
for (const [k, v] of Object.entries(row.config || {})) {
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
encryptedConfig[k] = v;
|
|
180
|
-
}
|
|
235
|
+
const sub = schema[k];
|
|
236
|
+
encryptedConfig[k] = sub
|
|
237
|
+
? transformConnectorSecrets(v, sub, 'encrypt')
|
|
238
|
+
: v;
|
|
181
239
|
}
|
|
182
240
|
out[id] = {
|
|
183
241
|
config: encryptedConfig,
|
package/lib/connectors/types.ts
CHANGED
|
@@ -18,15 +18,55 @@ export type ConnectorProtocol = 'browser' | 'http' | 'shell';
|
|
|
18
18
|
|
|
19
19
|
/** Schema for one settings or parameter field. */
|
|
20
20
|
export interface ConnectorFieldSchema {
|
|
21
|
-
type: 'string' | 'number' | 'boolean' | 'secret' | 'json' | 'select';
|
|
21
|
+
type: 'string' | 'number' | 'boolean' | 'secret' | 'json' | 'select' | 'instances';
|
|
22
22
|
label?: string;
|
|
23
23
|
description?: string;
|
|
24
24
|
required?: boolean;
|
|
25
25
|
default?: unknown;
|
|
26
26
|
/** select-type options */
|
|
27
27
|
options?: string[];
|
|
28
|
+
/**
|
|
29
|
+
* For `type: 'instances'` — schema for each row's sub-fields. The UI
|
|
30
|
+
* renders one collapsible group per row, with these as inner inputs;
|
|
31
|
+
* one field MUST be named `name` and serves as the row label / the
|
|
32
|
+
* `instance` key referenced by tools. Stored on disk as a
|
|
33
|
+
* JSON-stringified array; the tool-dispatcher overlay parses it
|
|
34
|
+
* before template expansion.
|
|
35
|
+
*/
|
|
36
|
+
fields?: Record<string, ConnectorFieldSchema>;
|
|
37
|
+
/**
|
|
38
|
+
* How this parameter's value is encoded when expanded into a URL path
|
|
39
|
+
* (`{args.X}` inside an HTTP tool's `request.url`). Default
|
|
40
|
+
* `uri_component` matches encodeURIComponent (slashes encoded). Use
|
|
41
|
+
* `none` when the value is a pre-formatted path that must stay literal
|
|
42
|
+
* (e.g. a Jenkins folder path `job/team/job/build`). `path_segments`
|
|
43
|
+
* encodes each `/`-separated segment individually but preserves the
|
|
44
|
+
* slashes (good for human-readable paths with spaces / unicode).
|
|
45
|
+
*
|
|
46
|
+
* Has no effect on values used in headers, query strings, or bodies —
|
|
47
|
+
* those go through token expansion without URL encoding either way.
|
|
48
|
+
*/
|
|
49
|
+
url_encoding?: 'uri_component' | 'none' | 'path_segments';
|
|
28
50
|
}
|
|
29
51
|
|
|
52
|
+
/**
|
|
53
|
+
* How a connector authenticates its HTTP tools. Declared at the
|
|
54
|
+
* connector level (manifest top) and inherited by every `protocol: http`
|
|
55
|
+
* tool; an individual tool can override (`tool.auth: { type: none }`)
|
|
56
|
+
* to skip auth for a public endpoint.
|
|
57
|
+
*
|
|
58
|
+
* Forge resolves the scheme into the right header / query param at
|
|
59
|
+
* dispatch time so manifests don't have to hand-craft Authorization
|
|
60
|
+
* headers (and so secrets like `password` can be base64-encoded
|
|
61
|
+
* server-side without leaking to manifests).
|
|
62
|
+
*/
|
|
63
|
+
export type ConnectorAuth =
|
|
64
|
+
| { type: 'none' }
|
|
65
|
+
| { type: 'basic'; username: string; password: string }
|
|
66
|
+
| { type: 'bearer'; token: string }
|
|
67
|
+
| { type: 'header'; name: string; value: string }
|
|
68
|
+
| { type: 'query'; name: string; value: string };
|
|
69
|
+
|
|
30
70
|
/**
|
|
31
71
|
* Server-side HTTP request shape, used by `protocol: http` tools.
|
|
32
72
|
* Template tokens {base_url}, {settings.*}, {args.*} are expanded at run time.
|
|
@@ -41,6 +81,36 @@ export interface HttpRequestSpec {
|
|
|
41
81
|
query?: Record<string, string>;
|
|
42
82
|
/** Body. string = sent as-is (templated); object = JSON.stringify'd (string values templated). */
|
|
43
83
|
body?: string | Record<string, unknown>;
|
|
84
|
+
/**
|
|
85
|
+
* Alternative body form: when set, Forge serialises the referenced
|
|
86
|
+
* value as `application/x-www-form-urlencoded` (URLSearchParams). Use
|
|
87
|
+
* the literal placeholder string `{args.NAME}` to point at an object-
|
|
88
|
+
* valued parameter — typical for triggering Jenkins builds with a
|
|
89
|
+
* dynamic `params` map, or any old-school form-POST API. Ignored if
|
|
90
|
+
* `body` is also set.
|
|
91
|
+
*/
|
|
92
|
+
body_form?: string | Record<string, unknown>;
|
|
93
|
+
/**
|
|
94
|
+
* Extra form-urlencoded keys merged into `body_form` server-side.
|
|
95
|
+
* Each value is a template (`{settings.gitlab_pat}` typical). Both
|
|
96
|
+
* the KEY and the VALUE are templated against settings. Keys with an
|
|
97
|
+
* empty / unsubstituted value are dropped (don't post blank
|
|
98
|
+
* secrets). The LLM never sees these — used to inject credentials a
|
|
99
|
+
* tool needs from settings without exposing them in the tool's
|
|
100
|
+
* declared parameters. Takes precedence over `body_form` if the
|
|
101
|
+
* same key appears in both.
|
|
102
|
+
*/
|
|
103
|
+
body_form_inject?: Record<string, string>;
|
|
104
|
+
/**
|
|
105
|
+
* Name of a `type: instances` settings field whose every row is
|
|
106
|
+
* injected as one form-urlencoded key/value pair. Each row must have
|
|
107
|
+
* `name` (Jenkins build-param name) and `value` (the value to send).
|
|
108
|
+
* Server-side resolution — LLM never sees these. Empty rows are
|
|
109
|
+
* dropped. Use alongside or instead of `body_form_inject` when the
|
|
110
|
+
* user needs to configure arbitrary key/value pairs at runtime
|
|
111
|
+
* rather than baking them into the manifest.
|
|
112
|
+
*/
|
|
113
|
+
body_form_inject_from?: string;
|
|
44
114
|
}
|
|
45
115
|
|
|
46
116
|
/**
|
|
@@ -102,6 +172,13 @@ export interface ConnectorTool {
|
|
|
102
172
|
|
|
103
173
|
/** shell/http: timeout in milliseconds. Default 30000, max 300000. */
|
|
104
174
|
timeout_ms?: number;
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* http: per-tool auth override. Falls back to the connector's
|
|
178
|
+
* top-level `auth` when omitted. Set `{ type: 'none' }` to skip
|
|
179
|
+
* auth on a public endpoint.
|
|
180
|
+
*/
|
|
181
|
+
auth?: ConnectorAuth;
|
|
105
182
|
}
|
|
106
183
|
|
|
107
184
|
/**
|
|
@@ -230,6 +307,15 @@ export interface ConnectorDefinition {
|
|
|
230
307
|
host_match?: string;
|
|
231
308
|
login_redirect?: string;
|
|
232
309
|
|
|
310
|
+
/**
|
|
311
|
+
* Default HTTP auth scheme applied to every `protocol: http` tool
|
|
312
|
+
* in this connector. Tools can override via their own `auth` field.
|
|
313
|
+
* Omitting it = no auth applied by Forge (tools may still hand-craft
|
|
314
|
+
* an Authorization header in `request.headers`, like the v0.1 gitlab
|
|
315
|
+
* and github-api manifests).
|
|
316
|
+
*/
|
|
317
|
+
auth?: ConnectorAuth;
|
|
318
|
+
|
|
233
319
|
// ─── 1:N suite — list of sibling entries sharing auth ──
|
|
234
320
|
connectors?: ConnectorEntry[];
|
|
235
321
|
|
|
@@ -147,6 +147,145 @@ tools:
|
|
|
147
147
|
timeout_ms: 15000
|
|
148
148
|
```
|
|
149
149
|
|
|
150
|
+
#### Auth schemes
|
|
151
|
+
|
|
152
|
+
Manifests can declare one auth scheme at the top and Forge applies it to every `protocol: http` tool — no need to hand-craft an `Authorization` header per tool, no manifest-side base64.
|
|
153
|
+
|
|
154
|
+
```yaml
|
|
155
|
+
# Connector-level (applies to all http tools).
|
|
156
|
+
auth:
|
|
157
|
+
type: basic # basic | bearer | header | query | none
|
|
158
|
+
username: '{settings.username}'
|
|
159
|
+
password: '{settings.api_token}'
|
|
160
|
+
|
|
161
|
+
# Variants:
|
|
162
|
+
auth:
|
|
163
|
+
type: bearer
|
|
164
|
+
token: '{settings.token}'
|
|
165
|
+
|
|
166
|
+
auth:
|
|
167
|
+
type: header
|
|
168
|
+
name: PRIVATE-TOKEN
|
|
169
|
+
value: '{settings.token}'
|
|
170
|
+
|
|
171
|
+
auth:
|
|
172
|
+
type: query
|
|
173
|
+
name: access_token
|
|
174
|
+
value: '{settings.token}'
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
A tool can override (or disable) the inherited scheme with its own `auth:` block. `{ type: none }` skips auth entirely (public endpoint).
|
|
178
|
+
|
|
179
|
+
#### Per-parameter URL encoding
|
|
180
|
+
|
|
181
|
+
Default behaviour for an `{args.X}` in a URL path is `encodeURIComponent` — slashes become `%2F`. This is right for GitLab-style project paths but wrong for systems that expect literal slashes (Jenkins folder paths). Override per parameter:
|
|
182
|
+
|
|
183
|
+
```yaml
|
|
184
|
+
parameters:
|
|
185
|
+
job_path:
|
|
186
|
+
type: string
|
|
187
|
+
url_encoding: none # uri_component (default) | none | path_segments
|
|
188
|
+
description: 'Pre-formatted Jenkins path with "job/" prefixes, e.g. "job/team-x/job/build".'
|
|
189
|
+
artifact_path:
|
|
190
|
+
type: string
|
|
191
|
+
url_encoding: path_segments # encode each / segment individually, preserve slashes
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
#### Form-urlencoded bodies (`body_form`)
|
|
195
|
+
|
|
196
|
+
For old-school form POST APIs (Jenkins `/buildWithParameters`, Slack `/chat.postMessage`, etc.). Point at a JSON-typed parameter and Forge serialises it with `URLSearchParams` (`application/x-www-form-urlencoded`):
|
|
197
|
+
|
|
198
|
+
```yaml
|
|
199
|
+
parameters:
|
|
200
|
+
params:
|
|
201
|
+
type: json
|
|
202
|
+
description: 'Flat object of build params: { "BRANCH": "main", "ENV": "stg" }'
|
|
203
|
+
request:
|
|
204
|
+
method: POST
|
|
205
|
+
url: '{settings.base_url}/{args.job_path}/buildWithParameters'
|
|
206
|
+
body_form: '{args.params}'
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
LLMs sometimes JSON-stringify nested objects even when the schema says `type: json` — Forge automatically `JSON.parse`'s string-form values, so both shapes work.
|
|
210
|
+
|
|
211
|
+
#### Server-side inject — credentials the LLM should never see
|
|
212
|
+
|
|
213
|
+
Two forms.
|
|
214
|
+
|
|
215
|
+
**1. `body_form_inject` (static keys, manifest-baked)** — useful when the manifest knows exactly which Jenkins / API param name the credential goes under.
|
|
216
|
+
|
|
217
|
+
```yaml
|
|
218
|
+
request:
|
|
219
|
+
body_form: '{args.params}'
|
|
220
|
+
body_form_inject:
|
|
221
|
+
GITLAB_PAT: '{settings.gitlab_pat}' # key + value both templated against settings
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Forge expands both the key and the value against settings (NOT args, so the LLM can't shadow). Empty / unsubstituted entries are dropped — optional secrets don't post blank values.
|
|
225
|
+
|
|
226
|
+
**2. `body_form_inject_from` (dynamic, user-configured per instance)** — when each Jenkins job uses different param names, let the user supply a list of `{name, value}` rows in the settings UI; Forge injects every row.
|
|
227
|
+
|
|
228
|
+
```yaml
|
|
229
|
+
# settings declaration:
|
|
230
|
+
settings:
|
|
231
|
+
inject_params:
|
|
232
|
+
type: instances # repeating-row UI
|
|
233
|
+
label: "Auto-inject build params"
|
|
234
|
+
fields:
|
|
235
|
+
name: { type: string, required: true, label: "Param name" }
|
|
236
|
+
value: { type: secret, required: true, label: "Value" }
|
|
237
|
+
|
|
238
|
+
# tool spec:
|
|
239
|
+
request:
|
|
240
|
+
body_form: '{args.params}'
|
|
241
|
+
body_form_inject_from: 'inject_params' # name of the instances settings field
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
User adds rows in the UI (e.g. `TOKEN_PASSWORD` → `<the-real-pat>`); Forge merges every row into the form body server-side. LLM passes only build-specific params, never sees the credentials.
|
|
245
|
+
|
|
246
|
+
#### Multi-instance support (`settings.instances`)
|
|
247
|
+
|
|
248
|
+
To let one install hit multiple servers of the same kind (prod + staging Jenkins, multiple GitLab tenants, etc.), declare your config under a `type: instances` field:
|
|
249
|
+
|
|
250
|
+
```yaml
|
|
251
|
+
settings:
|
|
252
|
+
instances:
|
|
253
|
+
type: instances
|
|
254
|
+
required: true
|
|
255
|
+
fields:
|
|
256
|
+
name: { type: string, required: true } # how the LLM picks one
|
|
257
|
+
base_url: { type: string, required: true }
|
|
258
|
+
username: { type: string, required: true }
|
|
259
|
+
api_token: { type: secret, required: true }
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
Each tool gains an implicit `instance` parameter (string). When the LLM passes `instance: "prod"`, Forge looks up the matching row from `settings.instances` and **overlays its fields onto the settings namespace** before template expansion — so your tool spec keeps using `{settings.base_url}` / `{settings.api_token}` as if they were flat. Omitting the arg defaults to the first row.
|
|
263
|
+
|
|
264
|
+
Pair `instances` with `body_form_inject_from` for fully per-instance secret injection (each Jenkins instance has its own list of credentials to inject).
|
|
265
|
+
|
|
266
|
+
Secrets nested inside an instance row (e.g. `api_token: { type: secret }` as a sub-field) are encrypted at rest by the same AES-256-GCM pipeline as flat top-level secrets; the UI masks them with bullets and the Settings → Connectors panel renders a "Replace" button to change them.
|
|
267
|
+
|
|
268
|
+
#### Nested `instances` (rows of rows)
|
|
269
|
+
|
|
270
|
+
A sub-field of an `instances` schema can itself be `type: instances`, and the renderer handles the nesting. This is how Jenkins's `inject_params` lives inside each instance row:
|
|
271
|
+
|
|
272
|
+
```yaml
|
|
273
|
+
settings:
|
|
274
|
+
instances:
|
|
275
|
+
type: instances
|
|
276
|
+
fields:
|
|
277
|
+
name: { type: string, required: true }
|
|
278
|
+
base_url: { type: string, required: true }
|
|
279
|
+
api_token: { type: secret, required: true }
|
|
280
|
+
inject_params: # nested instances inside a row
|
|
281
|
+
type: instances
|
|
282
|
+
fields:
|
|
283
|
+
name: { type: string, required: true }
|
|
284
|
+
value: { type: secret, required: true }
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
UI: each top-level instance row expands to a form that includes a nested rows-list for its sub-collection. Encryption recurses, so per-row secrets in either level encrypt correctly.
|
|
288
|
+
|
|
150
289
|
### test block
|
|
151
290
|
|
|
152
291
|
A connector can ship a self-test so the Settings → Connectors UI's
|
package/lib/init.ts
CHANGED
|
@@ -249,6 +249,7 @@ export function ensureInitialized() {
|
|
|
249
249
|
startWorkspaceProcess(); // spawns workspace-standalone
|
|
250
250
|
startBrowserBridgeProcess(); // spawns browser-bridge-standalone
|
|
251
251
|
startChatProcess(); // spawns chat-standalone
|
|
252
|
+
startMemoryProcess(); // spawns memory-standalone
|
|
252
253
|
|
|
253
254
|
const settings = loadSettings();
|
|
254
255
|
if (settings.tunnelAutoStart) {
|
|
@@ -402,3 +403,18 @@ function startBrowserBridgeProcess() {
|
|
|
402
403
|
});
|
|
403
404
|
tester.listen(bridgePort);
|
|
404
405
|
}
|
|
406
|
+
|
|
407
|
+
let memoryChild: ReturnType<typeof spawn> | null = null;
|
|
408
|
+
|
|
409
|
+
function startMemoryProcess() {
|
|
410
|
+
if (memoryChild) return;
|
|
411
|
+
// No HTTP port — pure background poller. Just spawn-if-not-running.
|
|
412
|
+
const script = join(process.cwd(), 'lib', 'memory-standalone.ts');
|
|
413
|
+
memoryChild = spawn('npx', ['tsx', script], {
|
|
414
|
+
stdio: ['ignore', 'inherit', 'inherit'],
|
|
415
|
+
env: { ...process.env },
|
|
416
|
+
detached: false,
|
|
417
|
+
});
|
|
418
|
+
memoryChild.on('exit', () => { memoryChild = null; });
|
|
419
|
+
console.log('[memory] Started standalone (pid:', memoryChild.pid, ')');
|
|
420
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compact chat messages into a summarizer-friendly transcript.
|
|
3
|
+
*
|
|
4
|
+
* Raw tool_use / tool_result blocks can each carry kilobytes of JSON
|
|
5
|
+
* (stack traces, encoded args, HTML responses). Feeding those into the
|
|
6
|
+
* summarizer LLM wastes input tokens and crowds out actual dialogue.
|
|
7
|
+
*
|
|
8
|
+
* This module flattens each Message into one or more text lines:
|
|
9
|
+
* - text blocks pass through (truncated to MAX_TEXT_CHARS)
|
|
10
|
+
* - tool_use → `tool[name](key1, key2, …)`
|
|
11
|
+
* - tool_result → `→ ok: <first line>` or `→ err: <first line>`
|
|
12
|
+
*
|
|
13
|
+
* Output is a plain string ready to drop into the summarizer prompt.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { ContentBlock, Message, ToolResultBlock, ToolUseBlock } from '../chat/types';
|
|
17
|
+
|
|
18
|
+
const MAX_TEXT_CHARS = 1200;
|
|
19
|
+
const MAX_TOOL_RESULT_CHARS = 200;
|
|
20
|
+
const MAX_INPUT_KEYS = 8;
|
|
21
|
+
|
|
22
|
+
export function compressMessagesForSummarizer(messages: Message[]): string {
|
|
23
|
+
const lines: string[] = [];
|
|
24
|
+
for (const m of messages) {
|
|
25
|
+
for (const block of m.blocks) {
|
|
26
|
+
const rendered = renderBlock(m.role, block);
|
|
27
|
+
if (rendered) lines.push(rendered);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return lines.join('\n');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function renderBlock(role: 'user' | 'assistant', block: ContentBlock): string | null {
|
|
34
|
+
if (block.type === 'text') {
|
|
35
|
+
const text = truncate(block.text.trim(), MAX_TEXT_CHARS);
|
|
36
|
+
if (!text) return null;
|
|
37
|
+
return `${role}: ${text}`;
|
|
38
|
+
}
|
|
39
|
+
if (block.type === 'tool_use') {
|
|
40
|
+
return `${role}: ${renderToolUse(block)}`;
|
|
41
|
+
}
|
|
42
|
+
if (block.type === 'tool_result') {
|
|
43
|
+
return `${role}: ${renderToolResult(block)}`;
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function renderToolUse(block: ToolUseBlock): string {
|
|
49
|
+
const keys = block.input && typeof block.input === 'object'
|
|
50
|
+
? Object.keys(block.input as Record<string, unknown>).slice(0, MAX_INPUT_KEYS)
|
|
51
|
+
: [];
|
|
52
|
+
const argsStr = keys.length > 0 ? `(${keys.join(', ')})` : '()';
|
|
53
|
+
return `tool[${block.name}]${argsStr}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function renderToolResult(block: ToolResultBlock): string {
|
|
57
|
+
const firstLine = (block.content ?? '').split(/\r?\n/, 1)[0] ?? '';
|
|
58
|
+
const head = truncate(firstLine, MAX_TOOL_RESULT_CHARS);
|
|
59
|
+
return block.is_error ? `→ err: ${head}` : `→ ok: ${head}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function truncate(s: string, max: number): string {
|
|
63
|
+
if (s.length <= max) return s;
|
|
64
|
+
return s.slice(0, max) + '…';
|
|
65
|
+
}
|