@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,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anthropic adapter — ported from the extension. Server-side variant
|
|
3
|
+
* (no dangerouslyAllowBrowser). OAuth tokens (sk-ant-oat-*) get the
|
|
4
|
+
* special beta header + authToken handling.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import Anthropic from '@anthropic-ai/sdk';
|
|
8
|
+
import type { Message } from '../types';
|
|
9
|
+
import type { LlmAdapter, LlmCallbacks, LlmRequest, LlmTurnResult, StopReason } from './types';
|
|
10
|
+
|
|
11
|
+
function historyToApi(history: Message[]): Anthropic.MessageParam[] {
|
|
12
|
+
const out: Anthropic.MessageParam[] = [];
|
|
13
|
+
for (const m of history) {
|
|
14
|
+
const content: Anthropic.ContentBlockParam[] = [];
|
|
15
|
+
for (const b of m.blocks) {
|
|
16
|
+
if (b.type === 'text') {
|
|
17
|
+
if (b.text.length > 0) content.push({ type: 'text', text: b.text });
|
|
18
|
+
} else if (b.type === 'tool_use') {
|
|
19
|
+
content.push({
|
|
20
|
+
type: 'tool_use',
|
|
21
|
+
id: b.id,
|
|
22
|
+
name: b.name,
|
|
23
|
+
input: (b.input ?? {}) as Record<string, unknown>,
|
|
24
|
+
});
|
|
25
|
+
} else if (b.type === 'tool_result') {
|
|
26
|
+
content.push({
|
|
27
|
+
type: 'tool_result',
|
|
28
|
+
tool_use_id: b.tool_use_id,
|
|
29
|
+
content: b.content,
|
|
30
|
+
is_error: b.is_error,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (content.length === 0) continue;
|
|
35
|
+
out.push({ role: m.role, content });
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function mapStop(r: Anthropic.Message['stop_reason']): StopReason {
|
|
41
|
+
switch (r) {
|
|
42
|
+
case 'end_turn': return 'end_turn';
|
|
43
|
+
case 'tool_use': return 'tool_use';
|
|
44
|
+
case 'max_tokens': return 'max_tokens';
|
|
45
|
+
case 'refusal': return 'refusal';
|
|
46
|
+
default: return 'other';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isOauthToken(key: string): boolean {
|
|
51
|
+
return key.startsWith('sk-ant-oat');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function makeAnthropicClient(apiKey: string, baseUrl: string): Anthropic {
|
|
55
|
+
const opts: ConstructorParameters<typeof Anthropic>[0] = {
|
|
56
|
+
baseURL: baseUrl || undefined,
|
|
57
|
+
};
|
|
58
|
+
if (isOauthToken(apiKey)) {
|
|
59
|
+
opts.authToken = apiKey;
|
|
60
|
+
opts.defaultHeaders = { 'anthropic-beta': 'oauth-2025-04-20' };
|
|
61
|
+
} else {
|
|
62
|
+
opts.apiKey = apiKey;
|
|
63
|
+
}
|
|
64
|
+
return new Anthropic(opts);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const anthropicAdapter: LlmAdapter = {
|
|
68
|
+
async stream(req: LlmRequest, cb: LlmCallbacks): Promise<LlmTurnResult> {
|
|
69
|
+
const client = makeAnthropicClient(req.apiKey, req.baseUrl);
|
|
70
|
+
|
|
71
|
+
const stream = client.messages.stream({
|
|
72
|
+
model: req.model,
|
|
73
|
+
max_tokens: req.maxTokens,
|
|
74
|
+
system: req.system,
|
|
75
|
+
tools: req.tools.map((t) => ({
|
|
76
|
+
name: t.name,
|
|
77
|
+
description: t.description,
|
|
78
|
+
input_schema: t.input_schema as Anthropic.Tool.InputSchema,
|
|
79
|
+
})),
|
|
80
|
+
messages: historyToApi(req.history),
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
stream.on('text', (delta: string) => cb.onTextDelta(delta));
|
|
84
|
+
|
|
85
|
+
const final = await stream.finalMessage();
|
|
86
|
+
|
|
87
|
+
const content: LlmTurnResult['content'] = [];
|
|
88
|
+
for (const block of final.content) {
|
|
89
|
+
if (block.type === 'text') {
|
|
90
|
+
content.push({ type: 'text', text: block.text });
|
|
91
|
+
} else if (block.type === 'tool_use') {
|
|
92
|
+
cb.onToolUse({ id: block.id, name: block.name, input: block.input });
|
|
93
|
+
content.push({ type: 'tool_use', id: block.id, name: block.name, input: block.input });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return { stopReason: mapStop(final.stop_reason), content };
|
|
98
|
+
},
|
|
99
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LLM client — picks an adapter based on the provider type.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { anthropicAdapter } from './anthropic';
|
|
6
|
+
import { openaiAdapter } from './openai';
|
|
7
|
+
import type { LlmAdapter, LlmCallbacks, LlmRequest, LlmTurnResult } from './types';
|
|
8
|
+
|
|
9
|
+
export type { LlmAdapter, LlmCallbacks, LlmRequest, LlmTurnResult, LlmTool, StopReason } from './types';
|
|
10
|
+
|
|
11
|
+
const ADAPTERS: Record<LlmRequest['provider'], LlmAdapter> = {
|
|
12
|
+
anthropic: anthropicAdapter,
|
|
13
|
+
openai: openaiAdapter,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function streamLlm(req: LlmRequest, cb: LlmCallbacks): Promise<LlmTurnResult> {
|
|
17
|
+
const adapter = ADAPTERS[req.provider];
|
|
18
|
+
if (!adapter) throw new Error(`unknown provider: ${req.provider}`);
|
|
19
|
+
return adapter.stream(req, cb);
|
|
20
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import type { ContentBlock, Message } from '../types';
|
|
2
|
+
import type { LlmAdapter, LlmCallbacks, LlmRequest, LlmTurnResult, StopReason } from './types';
|
|
3
|
+
|
|
4
|
+
// Minimal OpenAI-compatible Chat Completions types — covers what we use.
|
|
5
|
+
interface OAIToolCall {
|
|
6
|
+
index?: number;
|
|
7
|
+
id?: string;
|
|
8
|
+
type?: 'function';
|
|
9
|
+
function?: { name?: string; arguments?: string };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface OAITool {
|
|
13
|
+
type: 'function';
|
|
14
|
+
function: {
|
|
15
|
+
name: string;
|
|
16
|
+
description: string;
|
|
17
|
+
parameters: Record<string, unknown>;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type OAIMessage =
|
|
22
|
+
| { role: 'system'; content: string }
|
|
23
|
+
| { role: 'user'; content: string }
|
|
24
|
+
| { role: 'assistant'; content: string | null; tool_calls?: { id: string; type: 'function'; function: { name: string; arguments: string } }[] }
|
|
25
|
+
| { role: 'tool'; tool_call_id: string; content: string };
|
|
26
|
+
|
|
27
|
+
interface OAIChunk {
|
|
28
|
+
choices?: {
|
|
29
|
+
index?: number;
|
|
30
|
+
delta?: {
|
|
31
|
+
role?: string;
|
|
32
|
+
content?: string | null;
|
|
33
|
+
tool_calls?: OAIToolCall[];
|
|
34
|
+
};
|
|
35
|
+
finish_reason?: string | null;
|
|
36
|
+
}[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function defaultBase(baseUrl: string): string {
|
|
40
|
+
return (baseUrl || 'https://api.openai.com/v1').replace(/\/+$/, '');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function historyToApi(history: Message[]): OAIMessage[] {
|
|
44
|
+
const out: OAIMessage[] = [];
|
|
45
|
+
for (const m of history) {
|
|
46
|
+
if (m.role === 'user') {
|
|
47
|
+
// A user message in our model can be plain text OR a vehicle for tool_result blocks.
|
|
48
|
+
const toolResults = m.blocks.filter((b): b is Extract<ContentBlock, { type: 'tool_result' }> => b.type === 'tool_result');
|
|
49
|
+
if (toolResults.length > 0) {
|
|
50
|
+
for (const r of toolResults) {
|
|
51
|
+
out.push({ role: 'tool', tool_call_id: r.tool_use_id, content: r.content });
|
|
52
|
+
}
|
|
53
|
+
} else {
|
|
54
|
+
const text = m.blocks
|
|
55
|
+
.filter((b): b is Extract<ContentBlock, { type: 'text' }> => b.type === 'text')
|
|
56
|
+
.map((b) => b.text)
|
|
57
|
+
.join('\n');
|
|
58
|
+
if (text.length > 0) out.push({ role: 'user', content: text });
|
|
59
|
+
}
|
|
60
|
+
} else {
|
|
61
|
+
// assistant
|
|
62
|
+
const text = m.blocks
|
|
63
|
+
.filter((b): b is Extract<ContentBlock, { type: 'text' }> => b.type === 'text')
|
|
64
|
+
.map((b) => b.text)
|
|
65
|
+
.join('');
|
|
66
|
+
const tcs = m.blocks
|
|
67
|
+
.filter((b): b is Extract<ContentBlock, { type: 'tool_use' }> => b.type === 'tool_use')
|
|
68
|
+
.map((b) => ({
|
|
69
|
+
id: b.id,
|
|
70
|
+
type: 'function' as const,
|
|
71
|
+
function: { name: b.name, arguments: JSON.stringify(b.input ?? {}) },
|
|
72
|
+
}));
|
|
73
|
+
if (text.length === 0 && tcs.length === 0) continue;
|
|
74
|
+
out.push({
|
|
75
|
+
role: 'assistant',
|
|
76
|
+
content: text.length > 0 ? text : null,
|
|
77
|
+
...(tcs.length > 0 ? { tool_calls: tcs } : {}),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function mapStop(r: string | null | undefined): StopReason {
|
|
85
|
+
switch (r) {
|
|
86
|
+
case 'stop':
|
|
87
|
+
return 'end_turn';
|
|
88
|
+
case 'tool_calls':
|
|
89
|
+
case 'function_call':
|
|
90
|
+
return 'tool_use';
|
|
91
|
+
case 'length':
|
|
92
|
+
return 'max_tokens';
|
|
93
|
+
case 'content_filter':
|
|
94
|
+
return 'refusal';
|
|
95
|
+
default:
|
|
96
|
+
return 'other';
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export const openaiAdapter: LlmAdapter = {
|
|
101
|
+
async stream(req: LlmRequest, cb: LlmCallbacks): Promise<LlmTurnResult> {
|
|
102
|
+
const url = defaultBase(req.baseUrl) + '/chat/completions';
|
|
103
|
+
|
|
104
|
+
const tools: OAITool[] = req.tools.map((t) => ({
|
|
105
|
+
type: 'function',
|
|
106
|
+
function: {
|
|
107
|
+
name: t.name,
|
|
108
|
+
description: t.description,
|
|
109
|
+
parameters: t.input_schema,
|
|
110
|
+
},
|
|
111
|
+
}));
|
|
112
|
+
|
|
113
|
+
const body: Record<string, unknown> = {
|
|
114
|
+
model: req.model,
|
|
115
|
+
stream: true,
|
|
116
|
+
max_tokens: req.maxTokens,
|
|
117
|
+
messages: [{ role: 'system', content: req.system }, ...historyToApi(req.history)],
|
|
118
|
+
};
|
|
119
|
+
if (tools.length > 0) {
|
|
120
|
+
body.tools = tools;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const resp = await fetch(url, {
|
|
124
|
+
method: 'POST',
|
|
125
|
+
headers: {
|
|
126
|
+
'content-type': 'application/json',
|
|
127
|
+
...(req.apiKey ? { Authorization: `Bearer ${req.apiKey}` } : {}),
|
|
128
|
+
},
|
|
129
|
+
body: JSON.stringify(body),
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
if (!resp.ok || !resp.body) {
|
|
133
|
+
const text = await resp.text().catch(() => '');
|
|
134
|
+
throw new Error(`LLM ${resp.status}: ${text.slice(0, 400)}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const reader = resp.body.getReader();
|
|
138
|
+
const decoder = new TextDecoder();
|
|
139
|
+
|
|
140
|
+
let buffer = '';
|
|
141
|
+
let accumText = '';
|
|
142
|
+
// Tool call accumulators keyed by index (OpenAI streams partial JSON for arguments).
|
|
143
|
+
const toolByIndex = new Map<number, { id: string; name: string; argsRaw: string }>();
|
|
144
|
+
let finishReason: string | null | undefined;
|
|
145
|
+
|
|
146
|
+
while (true) {
|
|
147
|
+
const { value, done } = await reader.read();
|
|
148
|
+
if (done) break;
|
|
149
|
+
buffer += decoder.decode(value, { stream: true });
|
|
150
|
+
|
|
151
|
+
// SSE events are separated by blank lines; each event has one or more lines starting with "data: ".
|
|
152
|
+
const events = buffer.split('\n\n');
|
|
153
|
+
buffer = events.pop() ?? '';
|
|
154
|
+
|
|
155
|
+
for (const rawEvent of events) {
|
|
156
|
+
for (const line of rawEvent.split('\n')) {
|
|
157
|
+
if (!line.startsWith('data:')) continue;
|
|
158
|
+
const payload = line.slice(5).trim();
|
|
159
|
+
if (!payload || payload === '[DONE]') continue;
|
|
160
|
+
let chunk: OAIChunk;
|
|
161
|
+
try {
|
|
162
|
+
chunk = JSON.parse(payload) as OAIChunk;
|
|
163
|
+
} catch {
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
const choice = chunk.choices?.[0];
|
|
167
|
+
if (!choice) continue;
|
|
168
|
+
if (choice.finish_reason) finishReason = choice.finish_reason;
|
|
169
|
+
const delta = choice.delta;
|
|
170
|
+
if (!delta) continue;
|
|
171
|
+
if (typeof delta.content === 'string' && delta.content.length > 0) {
|
|
172
|
+
accumText += delta.content;
|
|
173
|
+
cb.onTextDelta(delta.content);
|
|
174
|
+
}
|
|
175
|
+
if (delta.tool_calls) {
|
|
176
|
+
for (const tc of delta.tool_calls) {
|
|
177
|
+
const idx = tc.index ?? 0;
|
|
178
|
+
let agg = toolByIndex.get(idx);
|
|
179
|
+
if (!agg) {
|
|
180
|
+
agg = { id: tc.id ?? '', name: tc.function?.name ?? '', argsRaw: '' };
|
|
181
|
+
toolByIndex.set(idx, agg);
|
|
182
|
+
}
|
|
183
|
+
if (tc.id && !agg.id) agg.id = tc.id;
|
|
184
|
+
if (tc.function?.name && !agg.name) agg.name = tc.function.name;
|
|
185
|
+
if (tc.function?.arguments) agg.argsRaw += tc.function.arguments;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const content: ContentBlock[] = [];
|
|
193
|
+
if (accumText.length > 0) content.push({ type: 'text', text: accumText });
|
|
194
|
+
|
|
195
|
+
// Sort tool calls by index for stable ordering.
|
|
196
|
+
const tcKeys = Array.from(toolByIndex.keys()).sort((a, b) => a - b);
|
|
197
|
+
for (const idx of tcKeys) {
|
|
198
|
+
const agg = toolByIndex.get(idx);
|
|
199
|
+
if (!agg) continue;
|
|
200
|
+
let input: unknown = {};
|
|
201
|
+
if (agg.argsRaw.length > 0) {
|
|
202
|
+
try {
|
|
203
|
+
input = JSON.parse(agg.argsRaw);
|
|
204
|
+
} catch {
|
|
205
|
+
input = { _raw: agg.argsRaw };
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const id = agg.id || `call_${idx}`;
|
|
209
|
+
cb.onToolUse({ id, name: agg.name, input });
|
|
210
|
+
content.push({ type: 'tool_use', id, name: agg.name, input });
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return { stopReason: mapStop(finishReason), content };
|
|
214
|
+
},
|
|
215
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LLM adapter types — ported from the extension.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ContentBlock, Message } from '../types';
|
|
6
|
+
|
|
7
|
+
export interface LlmTool {
|
|
8
|
+
name: string;
|
|
9
|
+
description: string;
|
|
10
|
+
input_schema: {
|
|
11
|
+
type: 'object';
|
|
12
|
+
properties: Record<string, unknown>;
|
|
13
|
+
required?: string[];
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface LlmCallbacks {
|
|
18
|
+
onTextDelta: (delta: string) => void;
|
|
19
|
+
onToolUse: (block: { id: string; name: string; input: unknown }) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type StopReason = 'end_turn' | 'tool_use' | 'max_tokens' | 'refusal' | 'error' | 'other';
|
|
23
|
+
|
|
24
|
+
export interface LlmTurnResult {
|
|
25
|
+
stopReason: StopReason;
|
|
26
|
+
content: ContentBlock[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface LlmRequest {
|
|
30
|
+
provider: 'anthropic' | 'openai';
|
|
31
|
+
apiKey: string;
|
|
32
|
+
baseUrl: string;
|
|
33
|
+
model: string;
|
|
34
|
+
system: string;
|
|
35
|
+
history: Message[];
|
|
36
|
+
tools: LlmTool[];
|
|
37
|
+
maxTokens: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface LlmAdapter {
|
|
41
|
+
stream(req: LlmRequest, cb: LlmCallbacks): Promise<LlmTurnResult>;
|
|
42
|
+
}
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LocalMemoryStore — SQLite-backed fallback for chat memory when
|
|
3
|
+
* Temper isn't configured. Implements the same surface as TemperClient
|
|
4
|
+
* (see lib/chat/memory-store.ts MemoryStore interface) so callers don't
|
|
5
|
+
* branch on the backend.
|
|
6
|
+
*
|
|
7
|
+
* Two tables under workflow.db:
|
|
8
|
+
* memory_blocks — key/value pairs (Temper "blocks")
|
|
9
|
+
* memory_episodes — append-only event log (Temper "episodes")
|
|
10
|
+
*
|
|
11
|
+
* Differences from Temper:
|
|
12
|
+
* - search() does case-insensitive LIKE over block values + episode
|
|
13
|
+
* content. No embeddings, no graph extraction.
|
|
14
|
+
* - writeEpisode() returns immediately; there is no async extraction.
|
|
15
|
+
* - Namespaces are honored at write/read time, but `scope=global` is
|
|
16
|
+
* a synonym for `scope=own` (local has a single tenant).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
import { getDb } from '@/src/core/db/database';
|
|
21
|
+
import { getDataDir } from '@/lib/dirs';
|
|
22
|
+
import type Database from 'better-sqlite3';
|
|
23
|
+
import type {
|
|
24
|
+
EpisodeInput,
|
|
25
|
+
MemoryBlock,
|
|
26
|
+
MemoryStore,
|
|
27
|
+
SearchHit,
|
|
28
|
+
} from './memory-store';
|
|
29
|
+
|
|
30
|
+
const DEFAULT_NAMESPACE = 'local';
|
|
31
|
+
|
|
32
|
+
interface BlockRow {
|
|
33
|
+
ns: string;
|
|
34
|
+
key: string;
|
|
35
|
+
value: string; // JSON-encoded
|
|
36
|
+
pinned: number; // 0/1
|
|
37
|
+
priority: number | null;
|
|
38
|
+
description: string | null;
|
|
39
|
+
scope: string; // 'own' | 'global'
|
|
40
|
+
updated_at: string; // ISO
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface EpisodeRow {
|
|
44
|
+
id: number;
|
|
45
|
+
ns: string;
|
|
46
|
+
content: string;
|
|
47
|
+
tags: string | null; // JSON-encoded array
|
|
48
|
+
source_type: string | null;
|
|
49
|
+
reference_time: string | null;
|
|
50
|
+
created_at: string; // ISO
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let schemaReady = false;
|
|
54
|
+
|
|
55
|
+
function db(): Database.Database {
|
|
56
|
+
return getDb(join(getDataDir(), 'workflow.db'));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function ensureSchema(): void {
|
|
60
|
+
if (schemaReady) return;
|
|
61
|
+
const conn = db();
|
|
62
|
+
conn.exec(`
|
|
63
|
+
CREATE TABLE IF NOT EXISTS memory_blocks (
|
|
64
|
+
ns TEXT NOT NULL,
|
|
65
|
+
key TEXT NOT NULL,
|
|
66
|
+
value TEXT NOT NULL,
|
|
67
|
+
pinned INTEGER NOT NULL DEFAULT 0,
|
|
68
|
+
priority INTEGER,
|
|
69
|
+
description TEXT,
|
|
70
|
+
scope TEXT NOT NULL DEFAULT 'own',
|
|
71
|
+
updated_at TEXT NOT NULL,
|
|
72
|
+
PRIMARY KEY (ns, key)
|
|
73
|
+
);
|
|
74
|
+
CREATE INDEX IF NOT EXISTS idx_memory_blocks_pinned
|
|
75
|
+
ON memory_blocks(ns, pinned);
|
|
76
|
+
|
|
77
|
+
CREATE TABLE IF NOT EXISTS memory_episodes (
|
|
78
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
79
|
+
ns TEXT NOT NULL,
|
|
80
|
+
content TEXT NOT NULL,
|
|
81
|
+
tags TEXT,
|
|
82
|
+
source_type TEXT,
|
|
83
|
+
reference_time TEXT,
|
|
84
|
+
created_at TEXT NOT NULL
|
|
85
|
+
);
|
|
86
|
+
CREATE INDEX IF NOT EXISTS idx_memory_episodes_ns_created
|
|
87
|
+
ON memory_episodes(ns, created_at DESC);
|
|
88
|
+
`);
|
|
89
|
+
schemaReady = true;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function nowIso(): string {
|
|
93
|
+
return new Date().toISOString();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function decodeValue(raw: string): unknown {
|
|
97
|
+
try {
|
|
98
|
+
return JSON.parse(raw);
|
|
99
|
+
} catch {
|
|
100
|
+
return raw;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function rowToBlock(r: BlockRow): MemoryBlock {
|
|
105
|
+
return {
|
|
106
|
+
key: r.key,
|
|
107
|
+
value: decodeValue(r.value),
|
|
108
|
+
pinned: !!r.pinned,
|
|
109
|
+
priority: r.priority ?? undefined,
|
|
110
|
+
description: r.description ?? undefined,
|
|
111
|
+
scope: (r.scope as 'own' | 'global') ?? 'own',
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export class LocalMemoryStore implements MemoryStore {
|
|
116
|
+
readonly kind = 'local' as const;
|
|
117
|
+
private readonly ns: string;
|
|
118
|
+
|
|
119
|
+
constructor(namespace?: string) {
|
|
120
|
+
this.ns = (namespace || DEFAULT_NAMESPACE).trim() || DEFAULT_NAMESPACE;
|
|
121
|
+
ensureSchema();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
get enabled(): boolean {
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
get currentNamespace(): string {
|
|
129
|
+
return this.ns;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async search(query: string, limit = 8): Promise<SearchHit[]> {
|
|
133
|
+
const q = (query || '').trim();
|
|
134
|
+
if (!q) return [];
|
|
135
|
+
const cap = Math.min(50, Math.max(1, limit));
|
|
136
|
+
const like = `%${q.replace(/[%_]/g, (m) => '\\' + m)}%`;
|
|
137
|
+
const conn = db();
|
|
138
|
+
|
|
139
|
+
const blockHits = conn.prepare(
|
|
140
|
+
`SELECT key, value, description, updated_at
|
|
141
|
+
FROM memory_blocks
|
|
142
|
+
WHERE ns = ?
|
|
143
|
+
AND (value LIKE ? ESCAPE '\\' OR key LIKE ? ESCAPE '\\' OR description LIKE ? ESCAPE '\\')
|
|
144
|
+
ORDER BY pinned DESC, updated_at DESC
|
|
145
|
+
LIMIT ?`,
|
|
146
|
+
).all(this.ns, like, like, like, cap) as Array<Pick<BlockRow, 'key' | 'value' | 'description' | 'updated_at'>>;
|
|
147
|
+
|
|
148
|
+
const episodeHits = conn.prepare(
|
|
149
|
+
`SELECT id, content, reference_time, created_at
|
|
150
|
+
FROM memory_episodes
|
|
151
|
+
WHERE ns = ?
|
|
152
|
+
AND content LIKE ? ESCAPE '\\'
|
|
153
|
+
ORDER BY created_at DESC
|
|
154
|
+
LIMIT ?`,
|
|
155
|
+
).all(this.ns, like, cap) as Array<Pick<EpisodeRow, 'id' | 'content' | 'reference_time' | 'created_at'>>;
|
|
156
|
+
|
|
157
|
+
const hits: SearchHit[] = [];
|
|
158
|
+
for (const b of blockHits) {
|
|
159
|
+
const valueStr = typeof decodeValue(b.value) === 'string'
|
|
160
|
+
? String(decodeValue(b.value))
|
|
161
|
+
: b.value;
|
|
162
|
+
hits.push({
|
|
163
|
+
id: `block:${b.key}`,
|
|
164
|
+
kind: 'fact',
|
|
165
|
+
fact: `${b.key}: ${truncate(valueStr, 200)}`,
|
|
166
|
+
valid_at: b.updated_at,
|
|
167
|
+
invalid_at: null,
|
|
168
|
+
namespace: this.ns,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
for (const e of episodeHits) {
|
|
172
|
+
hits.push({
|
|
173
|
+
id: `episode:${e.id}`,
|
|
174
|
+
kind: 'fact',
|
|
175
|
+
fact: truncate(e.content, 300),
|
|
176
|
+
valid_at: e.reference_time ?? e.created_at,
|
|
177
|
+
invalid_at: null,
|
|
178
|
+
namespace: this.ns,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
return hits.slice(0, cap);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async writeEpisode(ep: EpisodeInput, _asyncExtract = true): Promise<boolean> {
|
|
185
|
+
if (!ep.content || !ep.content.trim()) return false;
|
|
186
|
+
try {
|
|
187
|
+
const conn = db();
|
|
188
|
+
conn.prepare(
|
|
189
|
+
`INSERT INTO memory_episodes(ns, content, tags, source_type, reference_time, created_at)
|
|
190
|
+
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
191
|
+
).run(
|
|
192
|
+
ep.namespace || this.ns,
|
|
193
|
+
ep.content,
|
|
194
|
+
ep.tags && ep.tags.length ? JSON.stringify(ep.tags) : null,
|
|
195
|
+
ep.source_type ?? 'text',
|
|
196
|
+
ep.reference_time ?? nowIso(),
|
|
197
|
+
nowIso(),
|
|
198
|
+
);
|
|
199
|
+
return true;
|
|
200
|
+
} catch (err) {
|
|
201
|
+
console.warn('[local-memory] writeEpisode failed', err);
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async listBlocks(opts: { pinned?: boolean; scope?: 'own' | 'global' | 'both' } = {}): Promise<MemoryBlock[]> {
|
|
207
|
+
try {
|
|
208
|
+
const conn = db();
|
|
209
|
+
const where: string[] = ['ns = ?'];
|
|
210
|
+
const params: unknown[] = [this.ns];
|
|
211
|
+
if (opts.pinned) where.push('pinned = 1');
|
|
212
|
+
// scope=both means no filter; scope=own/global filters on stored value.
|
|
213
|
+
// Local store treats global ≡ own (single tenant), so we only filter when
|
|
214
|
+
// the caller explicitly asked for one or the other AND any rows actually
|
|
215
|
+
// have that scope. Otherwise return all.
|
|
216
|
+
const sql = `SELECT ns, key, value, pinned, priority, description, scope, updated_at
|
|
217
|
+
FROM memory_blocks
|
|
218
|
+
WHERE ${where.join(' AND ')}
|
|
219
|
+
ORDER BY pinned DESC, updated_at DESC`;
|
|
220
|
+
const rows = conn.prepare(sql).all(...params) as BlockRow[];
|
|
221
|
+
return rows.map(rowToBlock);
|
|
222
|
+
} catch (err) {
|
|
223
|
+
console.warn('[local-memory] listBlocks failed', err);
|
|
224
|
+
return [];
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async getBlock(key: string, _scope: 'own' | 'global' = 'own'): Promise<MemoryBlock | null> {
|
|
229
|
+
try {
|
|
230
|
+
const conn = db();
|
|
231
|
+
const row = conn.prepare(
|
|
232
|
+
`SELECT ns, key, value, pinned, priority, description, scope, updated_at
|
|
233
|
+
FROM memory_blocks
|
|
234
|
+
WHERE ns = ? AND key = ?`,
|
|
235
|
+
).get(this.ns, key) as BlockRow | undefined;
|
|
236
|
+
if (!row) return null;
|
|
237
|
+
return rowToBlock(row);
|
|
238
|
+
} catch (err) {
|
|
239
|
+
console.warn('[local-memory] getBlock failed', err);
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async putBlock(
|
|
245
|
+
key: string,
|
|
246
|
+
value: unknown,
|
|
247
|
+
extras: { pinned?: boolean; priority?: number; description?: string; scope?: 'own' | 'global' } = {},
|
|
248
|
+
): Promise<boolean> {
|
|
249
|
+
if (!key) return false;
|
|
250
|
+
try {
|
|
251
|
+
const conn = db();
|
|
252
|
+
conn.prepare(
|
|
253
|
+
`INSERT INTO memory_blocks(ns, key, value, pinned, priority, description, scope, updated_at)
|
|
254
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
255
|
+
ON CONFLICT(ns, key) DO UPDATE SET
|
|
256
|
+
value = excluded.value,
|
|
257
|
+
pinned = excluded.pinned,
|
|
258
|
+
priority = excluded.priority,
|
|
259
|
+
description = excluded.description,
|
|
260
|
+
scope = excluded.scope,
|
|
261
|
+
updated_at = excluded.updated_at`,
|
|
262
|
+
).run(
|
|
263
|
+
this.ns,
|
|
264
|
+
key,
|
|
265
|
+
JSON.stringify(value),
|
|
266
|
+
extras.pinned ? 1 : 0,
|
|
267
|
+
extras.priority ?? null,
|
|
268
|
+
extras.description ?? null,
|
|
269
|
+
extras.scope ?? 'own',
|
|
270
|
+
nowIso(),
|
|
271
|
+
);
|
|
272
|
+
return true;
|
|
273
|
+
} catch (err) {
|
|
274
|
+
console.warn('[local-memory] putBlock failed', err);
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async ping(): Promise<{ ok: boolean; message: string; pinned: number }> {
|
|
280
|
+
try {
|
|
281
|
+
const conn = db();
|
|
282
|
+
const row = conn.prepare(
|
|
283
|
+
`SELECT COUNT(*) AS n FROM memory_blocks WHERE ns = ? AND pinned = 1`,
|
|
284
|
+
).get(this.ns) as { n: number };
|
|
285
|
+
const n = row?.n ?? 0;
|
|
286
|
+
return {
|
|
287
|
+
ok: true,
|
|
288
|
+
message: `Local memory · ${n} pinned block${n === 1 ? '' : 's'}`,
|
|
289
|
+
pinned: n,
|
|
290
|
+
};
|
|
291
|
+
} catch (err) {
|
|
292
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
293
|
+
return { ok: false, message: `Local memory unavailable: ${msg}`, pinned: 0 };
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function truncate(s: string, max: number): string {
|
|
299
|
+
return s.length > max ? s.slice(0, max) + '…' : s;
|
|
300
|
+
}
|