@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,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat session storage — sqlite-backed. One row per session, one row
|
|
3
|
+
* per message; message.blocks is JSON-encoded.
|
|
4
|
+
*
|
|
5
|
+
* Lives in <data>/workflow.db alongside the rest of Forge's state.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { randomUUID } from 'node:crypto';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { getDb } from '@/src/core/db/database';
|
|
11
|
+
import { getDataDir } from '@/lib/dirs';
|
|
12
|
+
import type { ContentBlock, Message, Role, Session } from './types';
|
|
13
|
+
|
|
14
|
+
function db() {
|
|
15
|
+
return getDb(join(getDataDir(), 'workflow.db'));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface SessionRow {
|
|
19
|
+
id: string;
|
|
20
|
+
title: string | null;
|
|
21
|
+
created_at: number;
|
|
22
|
+
updated_at: number;
|
|
23
|
+
model: string | null;
|
|
24
|
+
provider: string | null;
|
|
25
|
+
system_prompt: string | null;
|
|
26
|
+
meta: string | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface MessageRow {
|
|
30
|
+
id: string;
|
|
31
|
+
session_id: string;
|
|
32
|
+
role: Role;
|
|
33
|
+
blocks: string;
|
|
34
|
+
ts: number;
|
|
35
|
+
error: string | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let initialized = false;
|
|
39
|
+
|
|
40
|
+
function ensureSchema(): void {
|
|
41
|
+
if (initialized) return;
|
|
42
|
+
const conn = db();
|
|
43
|
+
conn.exec(`
|
|
44
|
+
CREATE TABLE IF NOT EXISTS chat_sessions (
|
|
45
|
+
id TEXT PRIMARY KEY,
|
|
46
|
+
title TEXT,
|
|
47
|
+
created_at INTEGER NOT NULL,
|
|
48
|
+
updated_at INTEGER NOT NULL,
|
|
49
|
+
model TEXT,
|
|
50
|
+
provider TEXT,
|
|
51
|
+
system_prompt TEXT,
|
|
52
|
+
meta TEXT
|
|
53
|
+
);
|
|
54
|
+
CREATE INDEX IF NOT EXISTS idx_chat_sessions_updated ON chat_sessions(updated_at DESC);
|
|
55
|
+
|
|
56
|
+
CREATE TABLE IF NOT EXISTS chat_messages (
|
|
57
|
+
id TEXT PRIMARY KEY,
|
|
58
|
+
session_id TEXT NOT NULL,
|
|
59
|
+
role TEXT NOT NULL,
|
|
60
|
+
blocks TEXT NOT NULL,
|
|
61
|
+
ts INTEGER NOT NULL,
|
|
62
|
+
error TEXT,
|
|
63
|
+
FOREIGN KEY(session_id) REFERENCES chat_sessions(id) ON DELETE CASCADE
|
|
64
|
+
);
|
|
65
|
+
CREATE INDEX IF NOT EXISTS idx_chat_messages_session ON chat_messages(session_id, ts);
|
|
66
|
+
`);
|
|
67
|
+
initialized = true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function rowToSession(r: SessionRow): Session {
|
|
71
|
+
return {
|
|
72
|
+
id: r.id,
|
|
73
|
+
title: r.title,
|
|
74
|
+
created_at: r.created_at,
|
|
75
|
+
updated_at: r.updated_at,
|
|
76
|
+
model: r.model,
|
|
77
|
+
provider: r.provider,
|
|
78
|
+
system_prompt: r.system_prompt,
|
|
79
|
+
meta: r.meta ? safeParse(r.meta) : undefined,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function rowToMessage(r: MessageRow): Message {
|
|
84
|
+
return {
|
|
85
|
+
id: r.id,
|
|
86
|
+
session_id: r.session_id,
|
|
87
|
+
role: r.role,
|
|
88
|
+
blocks: safeParse<ContentBlock[]>(r.blocks) ?? [],
|
|
89
|
+
ts: r.ts,
|
|
90
|
+
error: r.error ?? undefined,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function safeParse<T = unknown>(s: string): T | undefined {
|
|
95
|
+
try { return JSON.parse(s) as T; } catch { return undefined; }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ─── Sessions ────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
export function createSession(opts: {
|
|
101
|
+
title?: string;
|
|
102
|
+
model?: string;
|
|
103
|
+
provider?: string;
|
|
104
|
+
system_prompt?: string;
|
|
105
|
+
meta?: Record<string, unknown>;
|
|
106
|
+
}): Session {
|
|
107
|
+
ensureSchema();
|
|
108
|
+
const now = Date.now();
|
|
109
|
+
const row: SessionRow = {
|
|
110
|
+
id: randomUUID(),
|
|
111
|
+
title: opts.title ?? null,
|
|
112
|
+
created_at: now,
|
|
113
|
+
updated_at: now,
|
|
114
|
+
model: opts.model ?? null,
|
|
115
|
+
provider: opts.provider ?? null,
|
|
116
|
+
system_prompt: opts.system_prompt ?? null,
|
|
117
|
+
meta: opts.meta ? JSON.stringify(opts.meta) : null,
|
|
118
|
+
};
|
|
119
|
+
db().prepare(`
|
|
120
|
+
INSERT INTO chat_sessions (id, title, created_at, updated_at, model, provider, system_prompt, meta)
|
|
121
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
122
|
+
`).run(row.id, row.title, row.created_at, row.updated_at, row.model, row.provider, row.system_prompt, row.meta);
|
|
123
|
+
return rowToSession(row);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function getSession(id: string): Session | null {
|
|
127
|
+
ensureSchema();
|
|
128
|
+
const r = db().prepare(`SELECT * FROM chat_sessions WHERE id = ?`).get(id) as SessionRow | undefined;
|
|
129
|
+
return r ? rowToSession(r) : null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function listSessions(limit = 50): Session[] {
|
|
133
|
+
ensureSchema();
|
|
134
|
+
const rows = db().prepare(`
|
|
135
|
+
SELECT * FROM chat_sessions ORDER BY updated_at DESC LIMIT ?
|
|
136
|
+
`).all(limit) as SessionRow[];
|
|
137
|
+
return rows.map(rowToSession);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function updateSession(id: string, patch: Partial<Pick<Session, 'title' | 'model' | 'provider' | 'system_prompt' | 'meta'>>): boolean {
|
|
141
|
+
ensureSchema();
|
|
142
|
+
const cur = getSession(id);
|
|
143
|
+
if (!cur) return false;
|
|
144
|
+
const merged = {
|
|
145
|
+
title: patch.title ?? cur.title,
|
|
146
|
+
model: patch.model ?? cur.model,
|
|
147
|
+
provider: patch.provider ?? cur.provider,
|
|
148
|
+
system_prompt: patch.system_prompt ?? cur.system_prompt,
|
|
149
|
+
meta: patch.meta !== undefined ? patch.meta : cur.meta,
|
|
150
|
+
};
|
|
151
|
+
db().prepare(`
|
|
152
|
+
UPDATE chat_sessions SET title=?, model=?, provider=?, system_prompt=?, meta=?, updated_at=?
|
|
153
|
+
WHERE id=?
|
|
154
|
+
`).run(
|
|
155
|
+
merged.title, merged.model, merged.provider, merged.system_prompt,
|
|
156
|
+
merged.meta ? JSON.stringify(merged.meta) : null,
|
|
157
|
+
Date.now(), id,
|
|
158
|
+
);
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function deleteSession(id: string): boolean {
|
|
163
|
+
ensureSchema();
|
|
164
|
+
const r = db().prepare(`DELETE FROM chat_sessions WHERE id = ?`).run(id);
|
|
165
|
+
return r.changes > 0;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Empty all messages in a session WITHOUT deleting the session row.
|
|
170
|
+
* Use for the main chat's "clear" — keep the session, drop history.
|
|
171
|
+
*/
|
|
172
|
+
export function clearSessionMessages(id: string): number {
|
|
173
|
+
ensureSchema();
|
|
174
|
+
const r = db().prepare(`DELETE FROM chat_messages WHERE session_id = ?`).run(id);
|
|
175
|
+
touchSession(id);
|
|
176
|
+
return r.changes;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Find-or-create the user's persistent main session. Identified by
|
|
181
|
+
* meta.kind === 'main'. Only one is ever returned (oldest wins if
|
|
182
|
+
* multiple — caller can collapse later). Job dispatches and the "new
|
|
183
|
+
* conversation" button create sessions with meta.kind = 'temp', so
|
|
184
|
+
* the main session is naturally pinned and never auto-deleted.
|
|
185
|
+
*/
|
|
186
|
+
export function ensureMainSession(): Session {
|
|
187
|
+
ensureSchema();
|
|
188
|
+
const existing = db().prepare(
|
|
189
|
+
`SELECT * FROM chat_sessions WHERE json_extract(meta, '$.kind') = 'main' ORDER BY created_at ASC LIMIT 1`,
|
|
190
|
+
).get() as SessionRow | undefined;
|
|
191
|
+
if (existing) return rowToSession(existing);
|
|
192
|
+
return createSession({ title: 'Main conversation', meta: { kind: 'main' } });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Fork a session: copy its current messages into a brand-new session.
|
|
197
|
+
* The fork starts as a temp session so it can be deleted later. Optional
|
|
198
|
+
* upToTs lets the caller branch from a specific point (everything with
|
|
199
|
+
* ts <= upToTs is copied).
|
|
200
|
+
*/
|
|
201
|
+
export function forkSession(srcId: string, opts: { title?: string; upToTs?: number } = {}): Session | null {
|
|
202
|
+
ensureSchema();
|
|
203
|
+
const src = getSession(srcId);
|
|
204
|
+
if (!src) return null;
|
|
205
|
+
const title = opts.title || `${src.title || 'Conversation'} (fork)`;
|
|
206
|
+
const fork = createSession({
|
|
207
|
+
title,
|
|
208
|
+
model: src.model || undefined,
|
|
209
|
+
provider: src.provider || undefined,
|
|
210
|
+
system_prompt: src.system_prompt || undefined,
|
|
211
|
+
meta: { kind: 'temp', forked_from: srcId },
|
|
212
|
+
});
|
|
213
|
+
const ts = opts.upToTs ?? Date.now();
|
|
214
|
+
const msgs = db().prepare(
|
|
215
|
+
`SELECT * FROM chat_messages WHERE session_id = ? AND ts <= ? ORDER BY ts ASC`,
|
|
216
|
+
).all(srcId, ts) as MessageRow[];
|
|
217
|
+
const ins = db().prepare(`
|
|
218
|
+
INSERT INTO chat_messages (id, session_id, role, blocks, ts, error)
|
|
219
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
220
|
+
`);
|
|
221
|
+
for (const m of msgs) {
|
|
222
|
+
ins.run(randomUUID(), fork.id, m.role, m.blocks, m.ts, m.error);
|
|
223
|
+
}
|
|
224
|
+
touchSession(fork.id);
|
|
225
|
+
return fork;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function touchSession(id: string): void {
|
|
229
|
+
db().prepare(`UPDATE chat_sessions SET updated_at = ? WHERE id = ?`).run(Date.now(), id);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ─── Messages ────────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
export function appendMessage(opts: {
|
|
235
|
+
session_id: string;
|
|
236
|
+
role: Role;
|
|
237
|
+
blocks: ContentBlock[];
|
|
238
|
+
error?: string;
|
|
239
|
+
}): Message {
|
|
240
|
+
ensureSchema();
|
|
241
|
+
const row: MessageRow = {
|
|
242
|
+
id: randomUUID(),
|
|
243
|
+
session_id: opts.session_id,
|
|
244
|
+
role: opts.role,
|
|
245
|
+
blocks: JSON.stringify(opts.blocks),
|
|
246
|
+
ts: Date.now(),
|
|
247
|
+
error: opts.error ?? null,
|
|
248
|
+
};
|
|
249
|
+
db().prepare(`
|
|
250
|
+
INSERT INTO chat_messages (id, session_id, role, blocks, ts, error)
|
|
251
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
252
|
+
`).run(row.id, row.session_id, row.role, row.blocks, row.ts, row.error);
|
|
253
|
+
touchSession(opts.session_id);
|
|
254
|
+
return rowToMessage(row);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function listMessages(session_id: string, opts?: { limit?: number; after_ts?: number }): Message[] {
|
|
258
|
+
ensureSchema();
|
|
259
|
+
const limit = opts?.limit ?? 500;
|
|
260
|
+
const after = opts?.after_ts ?? 0;
|
|
261
|
+
const rows = db().prepare(`
|
|
262
|
+
SELECT * FROM chat_messages WHERE session_id = ? AND ts > ?
|
|
263
|
+
ORDER BY ts ASC LIMIT ?
|
|
264
|
+
`).all(session_id, after, limit) as MessageRow[];
|
|
265
|
+
return rows.map(rowToMessage);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function deleteMessage(id: string): boolean {
|
|
269
|
+
ensureSchema();
|
|
270
|
+
const r = db().prepare(`DELETE FROM chat_messages WHERE id = ?`).run(id);
|
|
271
|
+
return r.changes > 0;
|
|
272
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram ↔ chat-standalone bridge.
|
|
3
|
+
*
|
|
4
|
+
* Each Telegram chat_id maps to one Forge chat session. The map is
|
|
5
|
+
* persisted as JSON in <dataDir>/telegram-chat-sessions.json so a
|
|
6
|
+
* conversation survives bot restarts.
|
|
7
|
+
*
|
|
8
|
+
* Per turn we:
|
|
9
|
+
* 1. Send a "thinking…" placeholder to Telegram, remember its message_id
|
|
10
|
+
* 2. Open SSE on chat-standalone, then POST the user text
|
|
11
|
+
* 3. Stream agent events:
|
|
12
|
+
* - tool_use → edit placeholder with "⚙ running <tool>(args)…"
|
|
13
|
+
* - text_delta → buffer; throttled edit (every 2s) to show progress
|
|
14
|
+
* - turn_done → final edit with full assistant text (split if >MAX)
|
|
15
|
+
* - error → final edit with "✗ <error>"
|
|
16
|
+
*
|
|
17
|
+
* Telegram has a ~30 msg/sec API limit and edit rate-limits per chat;
|
|
18
|
+
* we throttle edits to once per 2s during streaming.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
import { getDataDir } from '../dirs';
|
|
24
|
+
|
|
25
|
+
const CHAT_PORT = Number(process.env.CHAT_PORT) || 8408;
|
|
26
|
+
const BASE = `http://127.0.0.1:${CHAT_PORT}`;
|
|
27
|
+
const MAX_TELEGRAM_MSG = 4000; // a bit under 4096 to allow markers
|
|
28
|
+
const EDIT_THROTTLE_MS = 2000;
|
|
29
|
+
|
|
30
|
+
// ─── Session map persistence ──────────────────────────────
|
|
31
|
+
|
|
32
|
+
interface SessionMap {
|
|
33
|
+
/** telegram chat_id → forge session_id */
|
|
34
|
+
sessions: Record<string, string>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function mapFile(): string {
|
|
38
|
+
return join(getDataDir(), 'telegram-chat-sessions.json');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function loadMap(): SessionMap {
|
|
42
|
+
try {
|
|
43
|
+
const f = mapFile();
|
|
44
|
+
if (!existsSync(f)) return { sessions: {} };
|
|
45
|
+
return JSON.parse(readFileSync(f, 'utf-8')) as SessionMap;
|
|
46
|
+
} catch { return { sessions: {} }; }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function saveMap(m: SessionMap): void {
|
|
50
|
+
try {
|
|
51
|
+
mkdirSync(getDataDir(), { recursive: true });
|
|
52
|
+
writeFileSync(mapFile(), JSON.stringify(m, null, 2));
|
|
53
|
+
} catch (e) {
|
|
54
|
+
console.error('[chat-tg] save map failed', e);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function getTelegramSession(telegramChatId: number): string | null {
|
|
59
|
+
const m = loadMap();
|
|
60
|
+
return m.sessions[String(telegramChatId)] || null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function setTelegramSession(telegramChatId: number, sessionId: string): void {
|
|
64
|
+
const m = loadMap();
|
|
65
|
+
m.sessions[String(telegramChatId)] = sessionId;
|
|
66
|
+
saveMap(m);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function clearTelegramSession(telegramChatId: number): void {
|
|
70
|
+
const m = loadMap();
|
|
71
|
+
delete m.sessions[String(telegramChatId)];
|
|
72
|
+
saveMap(m);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ─── chat-standalone client ───────────────────────────────
|
|
76
|
+
|
|
77
|
+
async function createSession(title: string): Promise<string> {
|
|
78
|
+
const res = await fetch(`${BASE}/api/sessions`, {
|
|
79
|
+
method: 'POST',
|
|
80
|
+
headers: { 'content-type': 'application/json' },
|
|
81
|
+
body: JSON.stringify({ title }),
|
|
82
|
+
});
|
|
83
|
+
if (!res.ok) throw new Error(`create session: HTTP ${res.status}`);
|
|
84
|
+
const j = await res.json();
|
|
85
|
+
if (!j?.session?.id) throw new Error('create session: no id');
|
|
86
|
+
return j.session.id as string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function ensureSession(telegramChatId: number): Promise<string> {
|
|
90
|
+
const existing = getTelegramSession(telegramChatId);
|
|
91
|
+
if (existing) {
|
|
92
|
+
// Validate it still exists; if not, drop and recreate.
|
|
93
|
+
try {
|
|
94
|
+
const r = await fetch(`${BASE}/api/sessions/${encodeURIComponent(existing)}`);
|
|
95
|
+
if (r.status === 404) {
|
|
96
|
+
clearTelegramSession(telegramChatId);
|
|
97
|
+
} else if (r.ok) {
|
|
98
|
+
return existing;
|
|
99
|
+
}
|
|
100
|
+
} catch {/* fall through to recreate */}
|
|
101
|
+
}
|
|
102
|
+
const id = await createSession(`Telegram chat ${telegramChatId}`);
|
|
103
|
+
setTelegramSession(telegramChatId, id);
|
|
104
|
+
return id;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function openSse(sessionId: string): Promise<ReadableStreamDefaultReader<Uint8Array>> {
|
|
108
|
+
const res = await fetch(`${BASE}/api/sessions/${encodeURIComponent(sessionId)}/events`, {
|
|
109
|
+
headers: { accept: 'text/event-stream' },
|
|
110
|
+
});
|
|
111
|
+
if (!res.ok || !res.body) throw new Error(`SSE open: HTTP ${res.status}`);
|
|
112
|
+
const reader = res.body.getReader();
|
|
113
|
+
await reader.read(); // wait for initial `: subscribed` comment
|
|
114
|
+
return reader;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function postMessage(sessionId: string, text: string): Promise<void> {
|
|
118
|
+
const res = await fetch(`${BASE}/api/sessions/${encodeURIComponent(sessionId)}/messages`, {
|
|
119
|
+
method: 'POST',
|
|
120
|
+
headers: { 'content-type': 'application/json' },
|
|
121
|
+
body: JSON.stringify({ text }),
|
|
122
|
+
});
|
|
123
|
+
if (res.status !== 202 && !res.ok) {
|
|
124
|
+
throw new Error(`POST message: HTTP ${res.status}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ─── Turn driver ──────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
export interface TelegramTurnCallbacks {
|
|
131
|
+
/** Send placeholder; return message_id. Bot edits this with progress. */
|
|
132
|
+
sendPlaceholder: (text: string) => Promise<number | null>;
|
|
133
|
+
/** Edit a previously-sent placeholder. Failures are swallowed (rate-limit). */
|
|
134
|
+
editPlaceholder: (messageId: number, text: string) => Promise<void>;
|
|
135
|
+
/** Send a brand-new message (used for the final text when it splits). */
|
|
136
|
+
sendNew: (text: string) => Promise<number | null>;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
interface AgentEvent { type: string; data?: any; message_id?: string }
|
|
140
|
+
|
|
141
|
+
function summarizeToolInput(input: unknown): string {
|
|
142
|
+
try {
|
|
143
|
+
const s = JSON.stringify(input ?? {});
|
|
144
|
+
return s.length > 80 ? s.slice(0, 80) + '…' : s;
|
|
145
|
+
} catch { return ''; }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Run one chat turn against chat-standalone, streaming progress to Telegram.
|
|
150
|
+
* Returns the final assistant text (joined if multi-block).
|
|
151
|
+
*/
|
|
152
|
+
export async function runTelegramTurn(args: {
|
|
153
|
+
telegramChatId: number;
|
|
154
|
+
userText: string;
|
|
155
|
+
cb: TelegramTurnCallbacks;
|
|
156
|
+
}): Promise<{ ok: boolean; sessionId: string; finalText?: string; error?: string }> {
|
|
157
|
+
const sessionId = await ensureSession(args.telegramChatId);
|
|
158
|
+
|
|
159
|
+
const placeholderId = await args.cb.sendPlaceholder('🤖 thinking…');
|
|
160
|
+
|
|
161
|
+
let reader: ReadableStreamDefaultReader<Uint8Array>;
|
|
162
|
+
try {
|
|
163
|
+
reader = await openSse(sessionId);
|
|
164
|
+
} catch (e) {
|
|
165
|
+
const msg = (e as Error).message;
|
|
166
|
+
if (placeholderId) await args.cb.editPlaceholder(placeholderId, `✗ ${msg}`);
|
|
167
|
+
return { ok: false, sessionId, error: msg };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
await postMessage(sessionId, args.userText);
|
|
172
|
+
} catch (e) {
|
|
173
|
+
try { await reader.cancel(); } catch {}
|
|
174
|
+
const msg = (e as Error).message;
|
|
175
|
+
if (placeholderId) await args.cb.editPlaceholder(placeholderId, `✗ ${msg}`);
|
|
176
|
+
return { ok: false, sessionId, error: msg };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Streaming consume + throttled edits.
|
|
180
|
+
const decoder = new TextDecoder();
|
|
181
|
+
let buf = '';
|
|
182
|
+
let assistantText = '';
|
|
183
|
+
let currentTool: string | null = null;
|
|
184
|
+
let lastEditAt = 0;
|
|
185
|
+
let lastEditPayload = '';
|
|
186
|
+
let errorMsg: string | null = null;
|
|
187
|
+
|
|
188
|
+
async function maybeEdit(force = false) {
|
|
189
|
+
if (!placeholderId) return;
|
|
190
|
+
const now = Date.now();
|
|
191
|
+
if (!force && now - lastEditAt < EDIT_THROTTLE_MS) return;
|
|
192
|
+
let payload: string;
|
|
193
|
+
if (currentTool) {
|
|
194
|
+
payload = assistantText
|
|
195
|
+
? `${assistantText}\n\n⚙ ${currentTool}…`
|
|
196
|
+
: `⚙ ${currentTool}…`;
|
|
197
|
+
} else {
|
|
198
|
+
payload = assistantText || '🤖 thinking…';
|
|
199
|
+
}
|
|
200
|
+
if (payload.length > MAX_TELEGRAM_MSG) payload = payload.slice(0, MAX_TELEGRAM_MSG) + '…';
|
|
201
|
+
if (payload === lastEditPayload) return;
|
|
202
|
+
lastEditPayload = payload;
|
|
203
|
+
lastEditAt = now;
|
|
204
|
+
await args.cb.editPlaceholder(placeholderId, payload);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
outer:
|
|
208
|
+
while (true) {
|
|
209
|
+
const { value, done } = await reader.read();
|
|
210
|
+
if (done) break;
|
|
211
|
+
buf += decoder.decode(value, { stream: true });
|
|
212
|
+
|
|
213
|
+
let idx: number;
|
|
214
|
+
while ((idx = buf.indexOf('\n\n')) >= 0) {
|
|
215
|
+
const frame = buf.slice(0, idx);
|
|
216
|
+
buf = buf.slice(idx + 2);
|
|
217
|
+
const dataLine = frame.split('\n').find((l) => l.startsWith('data: '));
|
|
218
|
+
if (!dataLine) continue;
|
|
219
|
+
let evt: AgentEvent;
|
|
220
|
+
try { evt = JSON.parse(dataLine.slice(6)); } catch { continue; }
|
|
221
|
+
|
|
222
|
+
switch (evt.type) {
|
|
223
|
+
case 'text_delta': {
|
|
224
|
+
assistantText += String(evt.data?.delta || '');
|
|
225
|
+
void maybeEdit();
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
case 'tool_use': {
|
|
229
|
+
const name = String(evt.data?.name || '?');
|
|
230
|
+
currentTool = `${name}(${summarizeToolInput(evt.data?.input)})`;
|
|
231
|
+
void maybeEdit(true);
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
case 'tool_result': {
|
|
235
|
+
currentTool = null;
|
|
236
|
+
void maybeEdit(true);
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
case 'turn_done': {
|
|
240
|
+
break outer;
|
|
241
|
+
}
|
|
242
|
+
case 'error': {
|
|
243
|
+
errorMsg = String(evt.data?.error || 'unknown');
|
|
244
|
+
break outer;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
try { await reader.cancel(); } catch {}
|
|
250
|
+
|
|
251
|
+
if (errorMsg) {
|
|
252
|
+
if (placeholderId) await args.cb.editPlaceholder(placeholderId, `✗ ${errorMsg}`);
|
|
253
|
+
return { ok: false, sessionId, error: errorMsg };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const finalText = assistantText.trim() || '(no reply)';
|
|
257
|
+
|
|
258
|
+
// Final delivery: if it fits one message, edit the placeholder. Else
|
|
259
|
+
// edit with the first slice and send follow-ups as new messages.
|
|
260
|
+
if (placeholderId) {
|
|
261
|
+
const first = finalText.length > MAX_TELEGRAM_MSG ? finalText.slice(0, MAX_TELEGRAM_MSG) : finalText;
|
|
262
|
+
await args.cb.editPlaceholder(placeholderId, first);
|
|
263
|
+
if (finalText.length > MAX_TELEGRAM_MSG) {
|
|
264
|
+
for (let i = MAX_TELEGRAM_MSG; i < finalText.length; i += MAX_TELEGRAM_MSG) {
|
|
265
|
+
await args.cb.sendNew(finalText.slice(i, i + MAX_TELEGRAM_MSG));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
} else {
|
|
269
|
+
// Placeholder failed; send fresh
|
|
270
|
+
for (let i = 0; i < finalText.length; i += MAX_TELEGRAM_MSG) {
|
|
271
|
+
await args.cb.sendNew(finalText.slice(i, i + MAX_TELEGRAM_MSG));
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return { ok: true, sessionId, finalText };
|
|
276
|
+
}
|