team-chat 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +458 -2
  3. package/agents/rules.md +52 -0
  4. package/agents/runtimes/codex.md +14 -0
  5. package/bin/bridge +3 -0
  6. package/bin/runner +3 -0
  7. package/cli/lib/agents.js +97 -0
  8. package/cli/lib/docs.js +151 -0
  9. package/cli/lib/local.js +41 -0
  10. package/cli/lib/server.js +52 -0
  11. package/cli/lib/util.js +107 -0
  12. package/cli/team-chat.js +383 -0
  13. package/host/bridge/attempts.js +43 -0
  14. package/host/bridge/bridge.js +326 -0
  15. package/host/bridge/config.js +105 -0
  16. package/host/bridge/knowledge-outbox.js +96 -0
  17. package/host/bridge/launcher.js +126 -0
  18. package/host/bridge/main.js +87 -0
  19. package/host/bridge/progress.js +74 -0
  20. package/host/bridge/prompt.js +97 -0
  21. package/host/bridge/run-archive.js +200 -0
  22. package/host/bridge/thread.js +44 -0
  23. package/host/bridge/triage.js +97 -0
  24. package/host/bridge/workdir.js +41 -0
  25. package/host/knowledge/card.js +43 -0
  26. package/host/knowledge/errors.js +50 -0
  27. package/host/knowledge/lesson.js +153 -0
  28. package/host/knowledge/process.js +160 -0
  29. package/host/knowledge/scrub.js +29 -0
  30. package/host/knowledge/shell.js +74 -0
  31. package/host/knowledge/stack.js +229 -0
  32. package/host/knowledge/transcript.js +82 -0
  33. package/host/lib/chat-api.js +120 -0
  34. package/host/lib/log.js +6 -0
  35. package/host/lib/pid-lock.js +65 -0
  36. package/host/lib/shell-words.js +45 -0
  37. package/host/runner/bridge-process.js +82 -0
  38. package/host/runner/config.js +81 -0
  39. package/host/runner/main.js +69 -0
  40. package/host/runner/retention.js +40 -0
  41. package/host/runner/runner.js +161 -0
  42. package/package.json +67 -5
  43. package/server/dist/src/app.js +25 -0
  44. package/server/dist/src/app.module.js +50 -0
  45. package/server/dist/src/attachments/attachment-store.js +111 -0
  46. package/server/dist/src/attachments/attachments.controller.js +67 -0
  47. package/server/dist/src/attachments/mime.js +84 -0
  48. package/server/dist/src/attachments/uploads.interceptor.js +72 -0
  49. package/server/dist/src/auth/participant.guard.js +53 -0
  50. package/server/dist/src/bridges/bridge-restart.service.js +80 -0
  51. package/server/dist/src/bridges/bridges.controller.js +47 -0
  52. package/server/dist/src/chats/chats.controller.js +83 -0
  53. package/server/dist/src/chats/chats.service.js +128 -0
  54. package/server/dist/src/collector/collector.controller.js +71 -0
  55. package/server/dist/src/collector/collector.module.js +23 -0
  56. package/server/dist/src/collector/collector.service.js +111 -0
  57. package/server/dist/src/common/clock.js +20 -0
  58. package/server/dist/src/common/errors.js +37 -0
  59. package/server/dist/src/common/format.js +14 -0
  60. package/server/dist/src/common/http.js +17 -0
  61. package/server/dist/src/common/slug.js +28 -0
  62. package/server/dist/src/common/tokens.js +19 -0
  63. package/server/dist/src/common/validation.js +41 -0
  64. package/server/dist/src/config/config.js +39 -0
  65. package/server/dist/src/conversation.module.js +41 -0
  66. package/server/dist/src/db/database.js +73 -0
  67. package/server/dist/src/db/database.module.js +51 -0
  68. package/server/dist/src/db/schema.js +212 -0
  69. package/server/dist/src/decisions/decisions.controller.js +55 -0
  70. package/server/dist/src/decisions/decisions.service.js +109 -0
  71. package/server/dist/src/documents/documents.controller.js +87 -0
  72. package/server/dist/src/documents/documents.service.js +189 -0
  73. package/server/dist/src/health/health.controller.js +29 -0
  74. package/server/dist/src/knowledge/knowledge.controller.js +83 -0
  75. package/server/dist/src/knowledge/knowledge.module.js +25 -0
  76. package/server/dist/src/knowledge/knowledge.service.js +189 -0
  77. package/server/dist/src/local/docs-sync.service.js +97 -0
  78. package/server/dist/src/local/local.controller.js +197 -0
  79. package/server/dist/src/local/local.guard.js +55 -0
  80. package/server/dist/src/local/local.module.js +30 -0
  81. package/server/dist/src/local/setup.service.js +154 -0
  82. package/server/dist/src/local/transfer.service.js +151 -0
  83. package/server/dist/src/main.js +33 -0
  84. package/server/dist/src/mcp/chat-tools.js +282 -0
  85. package/server/dist/src/mcp/mcp.controller.js +58 -0
  86. package/server/dist/src/mcp/mcp.module.js +24 -0
  87. package/server/dist/src/mentions/mention-state.js +80 -0
  88. package/server/dist/src/mentions/mentions.controller.js +100 -0
  89. package/server/dist/src/mentions/mentions.service.js +203 -0
  90. package/server/dist/src/messages/message.presenter.js +102 -0
  91. package/server/dist/src/messages/messages.service.js +149 -0
  92. package/server/dist/src/messages/thread.controller.js +179 -0
  93. package/server/dist/src/participants/directory.service.js +68 -0
  94. package/server/dist/src/participants/participants.module.js +24 -0
  95. package/server/dist/src/participants/participants.service.js +84 -0
  96. package/server/dist/src/settings/settings.service.js +42 -0
  97. package/server/dist/src/sharing/consent.js +16 -0
  98. package/server/dist/src/sharing/sharing.module.js +22 -0
  99. package/server/dist/src/sharing/sharing.service.js +175 -0
  100. package/server/dist/src/team/runner.service.js +75 -0
  101. package/server/dist/src/team/team.controller.js +178 -0
  102. package/server/dist/src/team/team.module.js +25 -0
  103. package/server/dist/src/team/team.service.js +280 -0
  104. package/server/dist/src/web/template.js +57 -0
  105. package/server/dist/src/web/web-login.service.js +74 -0
  106. package/server/dist/src/web/web.controller.js +203 -0
  107. package/server/dist/src/web/web.module.js +25 -0
  108. package/server/drizzle/0000_init.sql +169 -0
  109. package/server/drizzle/0001_seed_roles.sql +25 -0
  110. package/server/drizzle/0002_knowledge.sql +17 -0
  111. package/server/drizzle/0003_sharing.sql +30 -0
  112. package/server/drizzle/meta/0000_snapshot.json +1224 -0
  113. package/server/drizzle/meta/0001_snapshot.json +1224 -0
  114. package/server/drizzle/meta/0002_snapshot.json +1359 -0
  115. package/server/drizzle/meta/0003_snapshot.json +1545 -0
  116. package/server/drizzle/meta/_journal.json +34 -0
  117. package/server/package.json +1 -0
  118. package/server/views/chat.html +865 -0
  119. package/server/views/documents.html +204 -0
  120. package/server/views/login.html +31 -0
  121. package/server/views/setup.html +60 -0
  122. package/server/views/team.html +369 -0
@@ -0,0 +1,326 @@
1
+ // Мост «чат → агент».
2
+ //
3
+ // Сам мост модель НИКОГДА не вызывает: он опрашивает обращения обычным HTTP и запускает
4
+ // агента, только когда обращение есть, — холостая проверка не стоит ни токена.
5
+ //
6
+ // Три свойства, ради которых он существует:
7
+ // * обращение гасится не чтением, а подтверждением после опубликованного ответа;
8
+ // * ответ привязан к КОНКРЕТНОМУ обращению (reply_to_id): два вопроса подряд не схлопнутся;
9
+ // * текст чужого сообщения уходит агенту через stdin, а не подстановкой в команду.
10
+
11
+ import fs from 'node:fs';
12
+ import os from 'node:os';
13
+ import path from 'node:path';
14
+ import { buildCard } from '../knowledge/card.js';
15
+ import { distillLesson } from '../knowledge/lesson.js';
16
+ import { compactTranscript } from '../knowledge/transcript.js';
17
+ import { ChatApi } from '../lib/chat-api.js';
18
+ import { splitWords } from '../lib/shell-words.js';
19
+ import { AttemptStore } from './attempts.js';
20
+ import { KnowledgeOutbox } from './knowledge-outbox.js';
21
+ import { AgentLauncher } from './launcher.js';
22
+ import { Beacon, ProgressReporter } from './progress.js';
23
+ import { buildPrompt } from './prompt.js';
24
+ import { RunArchive } from './run-archive.js';
25
+ import { Thread } from './thread.js';
26
+ import { deferredNote, triage, triageRules } from './triage.js';
27
+ import { resolveAgentCwd } from './workdir.js';
28
+
29
+ export class Bridge {
30
+ #config;
31
+ #api;
32
+ #thread;
33
+ #launcher;
34
+ #archive;
35
+ #outbox;
36
+ /** Работа после ответа: урок, отправка карточки. Ответ её не ждёт. */
37
+ #background = new Set();
38
+ #reporter;
39
+ #rules;
40
+ #log;
41
+ #print;
42
+
43
+ /**
44
+ * @param {object} deps
45
+ * @param {ReturnType<import('./config.js').bridgeConfig>} deps.config
46
+ * @param {ChatApi} deps.api
47
+ * @param {Thread} deps.thread
48
+ * @param {AgentLauncher} deps.launcher
49
+ * @param {RunArchive} deps.archive записи запусков
50
+ * @param {KnowledgeOutbox} deps.outbox карточки опыта по дороге в чат
51
+ * @param {ProgressReporter} deps.reporter
52
+ * @param {(...parts: any[]) => void} deps.log журнал моста
53
+ * @param {(text: string) => void} deps.print вывод для человека (--dry-run)
54
+ */
55
+ constructor({ config, api, thread, launcher, archive, outbox, reporter, log, print }) {
56
+ this.#config = config;
57
+ this.#api = api;
58
+ this.#thread = thread;
59
+ this.#launcher = launcher;
60
+ this.#archive = archive;
61
+ this.#outbox = outbox;
62
+ this.#reporter = reporter;
63
+ this.#rules = triageRules(config);
64
+ this.#log = log;
65
+ this.#print = print;
66
+ }
67
+
68
+ /**
69
+ * Один проход: разобрать открытые обращения и запустить агента — по разу на чат.
70
+ * @returns {Promise<number>} сколько обращений ушло агенту
71
+ */
72
+ async cycle({ dryRun = false } = {}) {
73
+ const mentions = await this.#api.mentions();
74
+ await this.#outbox.flush(); // чат отвечает — заодно дослать карточки, которые до него не дошли
75
+ if (mentions.length === 0) return 0;
76
+
77
+ const attempts = AttemptStore.load(this.#config.stateFile);
78
+ const context = {
79
+ attempts,
80
+ answered: message => this.#thread.answered(message),
81
+ // Бюджет считается лениво и один раз на чат за проход: на обращениях владельца он не нужен.
82
+ turnsInChat: memoize(chat => this.#thread.agentTurnsSinceOwner(chat)),
83
+ };
84
+
85
+ const toRun = [];
86
+ for (const mention of mentions) {
87
+ const verdict = await triage(mention, this.#rules, context);
88
+ if (verdict.kind === 'run') toRun.push(mention);
89
+ else await this.#apply(verdict, mention, attempts);
90
+ }
91
+
92
+ if (toRun.length) {
93
+ const groups = groupByChat(toRun);
94
+ const chats = [...groups.keys()].some(Boolean) ? await this.#chatSettings() : new Map();
95
+ for (const [slug, group] of groups) {
96
+ await this.#runGroup(group, chats.get(slug) ?? null, attempts, dryRun);
97
+ }
98
+ }
99
+
100
+ attempts.save();
101
+ return toRun.length;
102
+ }
103
+
104
+ async #apply(verdict, mention, attempts) {
105
+ if (verdict.kind === 'acknowledge') {
106
+ await this.#api.acknowledge(mention.id);
107
+ if (verdict.forget) attempts.forget(mention.id);
108
+ this.#log(`обращение #${mention.id}: ${verdict.reason}`);
109
+ return;
110
+ }
111
+ await this.#reporter.report([mention.id], verdict.state, { note: verdict.note });
112
+ if (verdict.reason) this.#log(`обращение #${mention.id}: ${verdict.reason}`);
113
+ }
114
+
115
+ /** Один запуск агента на все обращения одного чата: у каждого проекта свой контекст и каталог. */
116
+ async #runGroup(mentions, chat, attempts, dryRun) {
117
+ const ids = mentions.map(m => `#${m.message.id}`).join(', ');
118
+ this.#log(`обращений к обработке${chat ? ` в чате ${chat.slug}` : ''}: ${mentions.length} (${ids})`);
119
+
120
+ const prompt = this.#prompt(mentions, chat, await this.#experience(mentions));
121
+ if (dryRun) {
122
+ this.#log('--dry-run: агент не запускается, промпт ниже');
123
+ this.#print(`----- промпт -----\n${prompt}\n------------------`);
124
+ return;
125
+ }
126
+
127
+ const beacon = new Beacon(this.#reporter, {
128
+ mentionIds: mentions.map(m => m.id),
129
+ note: `работает над ${ids}`,
130
+ ttl: this.#config.beaconTtl,
131
+ intervalMs: this.#config.beaconIntervalMs,
132
+ });
133
+ const cwd = resolveAgentCwd(chat, this.#config, this.#log);
134
+ const record = this.#archive.begin({
135
+ handle: this.#config.handle,
136
+ runtime: this.#config.runtime,
137
+ executable: splitWords(this.#config.agentCommand)[0] ?? '',
138
+ chat,
139
+ mentions,
140
+ prompt,
141
+ cwd,
142
+ });
143
+
144
+ let code;
145
+ try {
146
+ code = await this.#launcher.run(prompt, { cwd, output: record.output, onStart: () => beacon.start() });
147
+ } finally {
148
+ await beacon.stop();
149
+ }
150
+ record.finish(code);
151
+
152
+ const outcome = outcomeOf(code);
153
+ try {
154
+ for (const mention of mentions) record.settle(mention.id, await this.#settle(mention, outcome, attempts));
155
+ } finally {
156
+ record.save();
157
+ }
158
+ this.#keepExperience(record, cwd, mentions.map(m => m.message.body));
159
+ }
160
+
161
+ /** Дождаться работы в фоне — уроков и отправки карточек: для тестов и чистой остановки. */
162
+ async settled() {
163
+ // Урок, дописавшись, сам отправляет карточку — новая работа появляется, пока ждём старую.
164
+ while (this.#background.size) await Promise.allSettled([...this.#background]);
165
+ }
166
+
167
+ /**
168
+ * Карточка опыта по запуску: факты — сразу, урок от нейросети — в фоне, чтобы следующее
169
+ * обращение его не ждало. Обе версии уходят в базу знаний чата. Сбой здесь не задевает
170
+ * ответ: ответ уже в треде и подтверждён, а карточка — польза на будущее.
171
+ */
172
+ #keepExperience(record, cwd, questions) {
173
+ let card;
174
+ let output;
175
+ try {
176
+ output = fs.readFileSync(record.output.stdout, 'utf8');
177
+ card = buildCard({ run: record.data, output, workdir: cwd ?? process.cwd() });
178
+ record.attachCard(card);
179
+ } catch (e) {
180
+ return this.#log(`карточка опыта не собралась: ${e.message}`);
181
+ }
182
+ this.#share(record);
183
+ if (!this.#config.distillCommand || !worthALesson(card)) return;
184
+
185
+ this.#inBackground(distillLesson(this.#config.distillCommand, compactTranscript(card.process.engine, output, questions), { cwd: os.tmpdir() })
186
+ .then(found => {
187
+ if (!found) return this.#log('урок: обобщать нечего');
188
+ record.attachCard({ ...card, ...found });
189
+ this.#share(record);
190
+ this.#log(`урок записан: ${found.solution.rule}`);
191
+ })
192
+ .catch(e => this.#log(`урок не получился: ${e.message}`)));
193
+ }
194
+
195
+ /** Карточку — в базу знаний чата. Сервер старше нескольких чатов её не примет — не шлём. */
196
+ #share(record) {
197
+ const chat = record.data.chat?.slug;
198
+ if (chat) this.#inBackground(this.#outbox.send({ chat, key: record.key, file: record.cardFile }));
199
+ }
200
+
201
+ #inBackground(promise) {
202
+ this.#background.add(promise);
203
+ promise.finally(() => this.#background.delete(promise));
204
+ }
205
+
206
+ /**
207
+ * Уроки прошлых запусков, похожие на эти обращения, — в задание. Не достали — работаем
208
+ * без них: ответить без подсказки лучше, чем не ответить.
209
+ */
210
+ async #experience(mentions) {
211
+ const chat = mentions[0].message.chat?.slug;
212
+ if (!chat) return [];
213
+ try {
214
+ return await this.#api.knowledge(chat, mentions.map(m => m.message.body).join('\n'), { limit: 3 });
215
+ } catch (e) {
216
+ this.#log(`опыт команды недоступен (${e.message}) — запускаю без него`);
217
+ return [];
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Закрывает обращение только ответ на НЕГО, а не код возврата: нулевой код не значит,
223
+ * что ответ дошёл до треда, ненулевой — что не дошёл.
224
+ *
225
+ * @returns {Promise<{outcome: string, attempt: number, answerMessageId?: number}>} для записи запуска
226
+ */
227
+ async #settle(mention, outcome, attempts) {
228
+ const id = mention.id;
229
+ const answer = await this.#thread.answerTo(mention.message);
230
+ if (answer) {
231
+ const attempt = attempts.count(id) + 1;
232
+ await this.#api.acknowledge(id);
233
+ attempts.forget(id);
234
+ this.#log(`обращение #${id} подтверждено`);
235
+ return { outcome: 'answered', attempt, answerMessageId: answer.id };
236
+ }
237
+
238
+ const { maxAttempts, stateFile } = this.#config;
239
+ const count = attempts.fail(id);
240
+ if (count >= maxAttempts) {
241
+ this.#log(`обращение #${id}: ${count} неудач подряд — откладываю. `
242
+ + `Разобраться и снять: удалить ключ из ${path.basename(stateFile)}`);
243
+ await this.#reporter.report([id], 'deferred', { note: deferredNote(count, maxAttempts, outcome.note) });
244
+ return { outcome: 'deferred', attempt: count };
245
+ }
246
+ this.#log(`обращение #${id}: ответа нет, попытка ${count} из ${maxAttempts}`);
247
+ await this.#reporter.report([id], outcome.state, { note: `${outcome.note}; попытка ${count} из ${maxAttempts}` });
248
+ return { outcome: outcome.state, attempt: count };
249
+ }
250
+
251
+ #prompt(mentions, chat, experience) {
252
+ const { handle, mcpName, rulesFile, runtimeNotesFile } = this.#config;
253
+ return buildPrompt({
254
+ mentions,
255
+ chat,
256
+ handle,
257
+ mcpName,
258
+ experience,
259
+ rules: fs.readFileSync(rulesFile, 'utf8'),
260
+ runtimeNotes: runtimeNotesFile && fs.existsSync(runtimeNotesFile) ? fs.readFileSync(runtimeNotesFile, 'utf8') : '',
261
+ });
262
+ }
263
+
264
+ /**
265
+ * Настройки чатов: название, описание проекта, каталог, команда. Не достали — работаем
266
+ * без них: ответить без описания проекта лучше, чем не ответить вовсе.
267
+ */
268
+ async #chatSettings() {
269
+ try {
270
+ return new Map((await this.#api.chats()).map(c => [c.slug, c]));
271
+ } catch (e) {
272
+ this.#log(`список чатов недоступен (${e.message}) — запускаю без описания проекта`);
273
+ return new Map();
274
+ }
275
+ }
276
+ }
277
+
278
+ /**
279
+ * Стоит ли запуск урока: агент работал — читал, искал, правил, запускал — или натыкался на
280
+ * ошибки. «Прочитал тред и ответил» — не опыт: лимиты на него тратить незачем.
281
+ */
282
+ function worthALesson(card) {
283
+ const { steps, tools, errors } = card.process;
284
+ return steps - (tools.chat ?? 0) >= 2 || errors.length > 0;
285
+ }
286
+
287
+ /** Мост в сборе — из настроек. */
288
+ export function createBridge(config, { log, print = text => console.log(text) }) {
289
+ const api = new ChatApi({ url: config.api, token: config.token });
290
+ return new Bridge({
291
+ config,
292
+ api,
293
+ thread: new Thread(api, config.handle),
294
+ launcher: new AgentLauncher(config, log),
295
+ archive: new RunArchive(config.runsDir),
296
+ outbox: new KnowledgeOutbox(config.outboxFile, api, log),
297
+ reporter: new ProgressReporter(api, log),
298
+ log,
299
+ print,
300
+ });
301
+ }
302
+
303
+ /** Исход запуска, пока не проверено, есть ли ответ. */
304
+ function outcomeOf(code) {
305
+ if (code === null) return { state: 'failed', note: 'не удалось запустить агента' };
306
+ if (code !== 0) return { state: 'failed', note: `агент завершился с кодом ${code}` };
307
+ return { state: 'done', note: 'обработка завершилась без ответа' };
308
+ }
309
+
310
+ /** slug → обращения; null — сервер старше нескольких чатов. */
311
+ function groupByChat(mentions) {
312
+ const groups = new Map();
313
+ for (const mention of mentions) {
314
+ const slug = mention.message.chat?.slug ?? null;
315
+ groups.set(slug, [...(groups.get(slug) ?? []), mention]);
316
+ }
317
+ return groups;
318
+ }
319
+
320
+ function memoize(fn) {
321
+ const cache = new Map();
322
+ return key => {
323
+ if (!cache.has(key)) cache.set(key, fn(key));
324
+ return cache.get(key);
325
+ };
326
+ }
@@ -0,0 +1,105 @@
1
+ // Настройки моста — только из окружения процесса: .env мост не читает. Окружение задаёт
2
+ // тот, кто мост запускает: раннер, юнит systemd или человек в терминале.
3
+
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ const ROOT = fileURLToPath(new URL('../../', import.meta.url));
9
+
10
+ export class ConfigError extends Error {}
11
+
12
+ /**
13
+ * @param {NodeJS.ProcessEnv} env
14
+ * @param {object} overrides поля поверх окружения: флаги командной строки, тесты
15
+ */
16
+ export function bridgeConfig(env = process.env, overrides = {}) {
17
+ if (!env.CHAT_TOKEN) throw new ConfigError('нужен CHAT_TOKEN');
18
+
19
+ const api = env.CHAT_API || 'http://localhost:8600/api';
20
+ const runtime = env.CHAT_RUNTIME || '';
21
+ const beaconTtl = int(env, 'CHAT_BEACON_TTL', 90);
22
+
23
+ const config = {
24
+ api,
25
+ token: env.CHAT_TOKEN,
26
+ handle: env.CHAT_HANDLE || 'claude',
27
+
28
+ // Общие правила чата — одни на всех агентов. Роль (за что агент отвечает) приходит
29
+ // с сервером в каждом обращении: роли заводит владелец в интерфейсе.
30
+ rulesFile: env.CHAT_SETTINGS || path.join(ROOT, 'agents', 'rules.md'),
31
+ // Движок (claude, codex…) и его особенности — песочница, доступы. Они про движок, а не про роль.
32
+ runtime,
33
+ runtimeNotesFile: runtime ? path.join(ROOT, 'agents', 'runtimes', `${runtime}.md`) : null,
34
+
35
+ // MCP-подключение к чату для {mcp_config}: у агентов на движке claude оно собирается
36
+ // на каждый запуск под токеном агента — иначе все они писали бы под одним именем.
37
+ mcpUrl: env.CHAT_MCP_URL || `${api.replace(/\/api\/?$/, '')}/mcp`,
38
+ // Под этим именем агент видит инструменты чата (mcp__<имя>__…); оно же стоит
39
+ // в --allowedTools команды. qbazar-chat — имя первой установки.
40
+ mcpName: env.CHAT_MCP_NAME || 'qbazar-chat',
41
+
42
+ // Команда запуска агента. Промпт ВСЕГДА уходит через stdin, поэтому в шаблоне его нет.
43
+ // В режиме -p список --allowedTools — это ВЕСЬ белый список: без mcp__qbazar-chat
44
+ // инструменты чата отказывают по правам. Edit/Write — по решению владельца (#67).
45
+ agentCommand: env.CHAT_AGENT_CMD || 'claude -p --allowedTools Bash Read Grep Glob Edit Write mcp__qbazar-chat',
46
+ // {session} в команде — продолжение сессии. Пусто — свежая сессия: дёшево, память
47
+ // берётся из контекста чата и репозиториев.
48
+ resume: env.CHAT_RESUME || '',
49
+
50
+ // Каталог агента, заданный машиной. Главнее каталога проекта: у Codex это песочница,
51
+ // и сервер не должен её отменять. Нужен и агенту-Windows из WSL: WSL-путь для него
52
+ // не годится как cwd, и процесс молча падает в C:\Windows\Temp.
53
+ agentCwd: env.CHAT_AGENT_CWD || '',
54
+ // Каталог проекта приходит с сервером (workdir чата). Сервер не должен уметь отправить
55
+ // агента куда угодно: принимаем только существующий каталог внутри этого корня.
56
+ workdirRoot: env.CHAT_WORKDIR_ROOT || os.homedir(),
57
+
58
+ // Защита от переклички: ответы агентов сами содержат @упоминания и породили бы новые
59
+ // обращения — и так по кругу. По умолчанию запускаемся только на сообщения владельца.
60
+ triggerRoles: (env.CHAT_TRIGGER_ROLES || 'owner').split(','),
61
+ maxAttempts: int(env, 'CHAT_MAX_ATTEMPTS', 3),
62
+ // Бюджет ходов на разговор агентов между собой, от последней реплики владельца.
63
+ // Исчерпан — агенты молчат, пока владелец не скажет следующее слово.
64
+ turnBudget: int(env, 'CHAT_AGENT_TURN_BUDGET', 3),
65
+
66
+ // Индикатор «агент работает»: срок годности отметки и как часто её продлевать.
67
+ // Втрое чаще срока — один пропущенный удар индикатор не гасит, а упавший мост
68
+ // гасит его сам за полторы минуты.
69
+ beaconTtl,
70
+ beaconIntervalMs: Math.max(5, Math.floor(beaconTtl / 3)) * 1000,
71
+
72
+ intervalMs: int(env, 'CHAT_INTERVAL', 10) * 1000,
73
+ // Счётчики попыток и замок. При установке пакетом код лежит там, куда писать
74
+ // не стоит, — тогда они живут в каталоге данных.
75
+ stateDir: env.CHAT_STATE_DIR || ROOT,
76
+ // Записи запусков агента (run-archive.js): задание, ход работы, исход. Это данные,
77
+ // а не временный лог, — поэтому не /tmp и не каталог кода.
78
+ runsDir: env.CHAT_RUNS_DIR || path.join(os.homedir(), 'agent-workspace', 'runs'),
79
+ // Команда урока для карточки опыта — дешёвая модель без инструментов (distill в runner.json).
80
+ // Пусто — карточка без урока: только факты.
81
+ distillCommand: env.CHAT_DISTILL_CMD || '',
82
+
83
+ ...overrides,
84
+ };
85
+
86
+ if (config.resume && !config.agentCommand.includes('{session}')) {
87
+ throw new ConfigError('CHAT_RESUME задан, но в CHAT_AGENT_CMD нет плейсхолдера {session}');
88
+ }
89
+
90
+ return Object.freeze({
91
+ ...config,
92
+ stateFile: path.join(config.stateDir, `.bridge-state-${config.handle}.json`),
93
+ lockFile: path.join(config.stateDir, `.bridge-${config.handle}.lock`),
94
+ // Карточки опыта, которые не дошли до чата: уйдут, когда он ответит.
95
+ outboxFile: path.join(config.stateDir, `.bridge-outbox-${config.handle}.json`),
96
+ });
97
+ }
98
+
99
+ function int(env, name, fallback) {
100
+ const raw = env[name];
101
+ if (raw === undefined || raw === '') return fallback;
102
+ const value = Number(raw);
103
+ if (!Number.isInteger(value) || value < 0) throw new ConfigError(`${name}=${raw} — нужно целое число`);
104
+ return value;
105
+ }
@@ -0,0 +1,96 @@
1
+ // Карточки опыта по дороге в чат. Чат может не ответить — перезапуск, сеть, — и карточка
2
+ // не должна от этого пропасть: она ждёт в файле очереди и уходит при следующем проходе.
3
+ //
4
+ // Отправки идут строго по одной: факты и урок одного запуска уходят разными запросами,
5
+ // и опоздавшие факты не должны затереть уже дошедший урок.
6
+
7
+ import fs from 'node:fs';
8
+ import { ChatHttpError } from '../lib/chat-api.js';
9
+
10
+ export class KnowledgeOutbox {
11
+ #file;
12
+ #api;
13
+ #log;
14
+ /** key записи запуска → {chat, key, file}: сама карточка — в файле, там всегда последняя версия. */
15
+ #pending;
16
+ #tail = Promise.resolve();
17
+
18
+ /**
19
+ * @param {string} file файл очереди
20
+ * @param {import('../lib/chat-api.js').ChatApi} api
21
+ * @param {(...parts: any[]) => void} log
22
+ */
23
+ constructor(file, api, log) {
24
+ this.#file = file;
25
+ this.#api = api;
26
+ this.#log = log;
27
+ this.#pending = new Map(load(file).map(entry => [entry.key, entry]));
28
+ }
29
+
30
+ /** Отправить карточку запуска; не дошла — останется в очереди. */
31
+ send({ chat, key, file }) {
32
+ return this.#serial(async () => {
33
+ const entry = { chat, key, file };
34
+ if (await this.#deliver(entry)) this.#pending.delete(key);
35
+ else this.#pending.set(key, entry);
36
+ this.#save();
37
+ });
38
+ }
39
+
40
+ /** Дослать то, что не дошло. Чат снова не ответил — остальное ждёт следующего раза. */
41
+ flush() {
42
+ if (this.#pending.size === 0) return this.#tail;
43
+ return this.#serial(async () => {
44
+ for (const entry of [...this.#pending.values()]) {
45
+ if (!await this.#deliver(entry)) break;
46
+ this.#pending.delete(entry.key);
47
+ }
48
+ this.#save();
49
+ });
50
+ }
51
+
52
+ /** @returns {Promise<boolean>} true — дошла или повторять бессмысленно; false — повторить позже */
53
+ async #deliver({ chat, key, file }) {
54
+ let card;
55
+ try {
56
+ card = JSON.parse(fs.readFileSync(file, 'utf8'));
57
+ } catch {
58
+ this.#log(`карточка ${key}: файла нет — отправлять нечего`);
59
+ return true;
60
+ }
61
+ try {
62
+ await this.#api.saveCard(chat, key, card);
63
+ return true;
64
+ } catch (e) {
65
+ // Отказ (4xx) повтором не исправить: старый сервер без базы знаний, неверная карточка.
66
+ if (e instanceof ChatHttpError && e.status < 500) {
67
+ this.#log(`карточка ${key}: ${e.message} — не отправлена`);
68
+ return true;
69
+ }
70
+ this.#log(`карточка ${key}: ${e.message} — отправлю позже`);
71
+ return false;
72
+ }
73
+ }
74
+
75
+ #serial(task) {
76
+ this.#tail = this.#tail.then(task).catch(e => this.#log(`очередь карточек: ${e.message}`));
77
+ return this.#tail;
78
+ }
79
+
80
+ #save() {
81
+ if (this.#pending.size === 0) {
82
+ fs.rmSync(this.#file, { force: true });
83
+ return;
84
+ }
85
+ fs.writeFileSync(this.#file, JSON.stringify([...this.#pending.values()], null, 2) + '\n', { mode: 0o600 });
86
+ }
87
+ }
88
+
89
+ function load(file) {
90
+ try {
91
+ const entries = JSON.parse(fs.readFileSync(file, 'utf8'));
92
+ return Array.isArray(entries) ? entries.filter(e => e?.chat && e?.key && e?.file) : [];
93
+ } catch {
94
+ return []; // очереди нет или она испорчена — карточки остались в записях запусков
95
+ }
96
+ }
@@ -0,0 +1,126 @@
1
+ // Запуск агента: команда из настроек машины, задание — через stdin, вывод — в файлы,
2
+ // которые назовёт мост (запись запуска).
3
+
4
+ import { spawn } from 'node:child_process';
5
+ import crypto from 'node:crypto';
6
+ import fs from 'node:fs';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+ import { splitWords } from '../lib/shell-words.js';
10
+
11
+ /** Переменные, которые агент получает от моста: ими он ходит в чат. */
12
+ const CHAT_VARS = ['CHAT_TOKEN', 'CHAT_API', 'QBAZAR_CHAT_TOKEN'];
13
+
14
+ export class AgentLauncher {
15
+ #config;
16
+ #log;
17
+
18
+ /** @param {ReturnType<import('./config.js').bridgeConfig>} config */
19
+ constructor(config, log) {
20
+ this.#config = config;
21
+ this.#log = log;
22
+ }
23
+
24
+ /**
25
+ * Запустить агента и дождаться его.
26
+ *
27
+ * @param {string} prompt задание — только через stdin: текст чужого сообщения в аргументах
28
+ * команды был бы подстановкой в командную строку
29
+ * @param {object} options
30
+ * @param {string|null} options.cwd
31
+ * @param {{stdout: string, stderr: string}} options.output куда писать вывод агента. Раздельно:
32
+ * stdout у движков — ход работы построчным JSON, и ошибки его не должны портить
33
+ * @param {() => void} [options.onStart] сразу после создания процесса, не раньше
34
+ * @returns {Promise<number|null>} код возврата; null — процесс не создан
35
+ */
36
+ async run(prompt, { cwd, output, onStart = () => {} }) {
37
+ const mcpFile = this.#needsMcpConfig() ? this.#writeMcpConfig() : null;
38
+ const out = fs.openSync(output.stdout, 'w', 0o600);
39
+ const err = fs.openSync(output.stderr, 'w', 0o600);
40
+ this.#log(`запускаю агента — десятки секунд или минуты, запись: ${path.dirname(output.stdout)}`);
41
+ const started = Date.now();
42
+
43
+ try {
44
+ const [command, ...args] = this.#command(mcpFile);
45
+ const child = spawn(command, args, { cwd: cwd ?? undefined, env: this.#env(), stdio: ['pipe', out, err] });
46
+ // Агент вправе выйти, не дочитав задание: EPIPE здесь — не ошибка моста.
47
+ child.stdin.on('error', () => {});
48
+
49
+ try {
50
+ await spawned(child);
51
+ } catch (e) {
52
+ this.#log(`агента запустить не удалось: ${e.message}`);
53
+ return null;
54
+ }
55
+
56
+ onStart();
57
+ child.stdin.end(prompt);
58
+
59
+ const code = await exited(child);
60
+ const took = Math.round((Date.now() - started) / 1000);
61
+ this.#log(code === 0
62
+ ? `агент отработал за ${took} с`
63
+ : `агент завершился с кодом ${code} за ${took} с — см. ${output.stderr}`);
64
+ return code;
65
+ } finally {
66
+ fs.closeSync(out);
67
+ fs.closeSync(err);
68
+ if (mcpFile) fs.rmSync(mcpFile, { force: true });
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Подстановки — в уже разобранные слова, а не в строку: путь с пробелом или
74
+ * кавычкой не развалит команду и не допишет в неё лишнего.
75
+ */
76
+ #command(mcpFile) {
77
+ const words = splitWords(this.#config.agentCommand);
78
+ const filled = words.map(word => word
79
+ .replaceAll('{session}', this.#config.resume)
80
+ .replaceAll('{mcp_config}', mcpFile ?? ''));
81
+ // Плейсхолдер без значения (свежая сессия) — не пустой аргумент, а его отсутствие.
82
+ return filled.filter((word, i) => word !== '' || words[i] === '');
83
+ }
84
+
85
+ #env() {
86
+ const { token, api } = this.#config;
87
+ // QBAZAR_CHAT_TOKEN — под этим именем токен ждёт MCP-клиент Codex (--bearer-token-env-var).
88
+ const env = { ...process.env, CHAT_TOKEN: token, CHAT_API: api, QBAZAR_CHAT_TOKEN: token };
89
+ // Через границу WSL→Windows переменные сами НЕ проходят — нужен WSLENV.
90
+ // Агенту на Linux эта строка безвредна.
91
+ const wslenv = new Set((env.WSLENV ?? '').split(':').filter(Boolean));
92
+ env.WSLENV = [...new Set([...wslenv, ...CHAT_VARS])].sort().join(':');
93
+ return env;
94
+ }
95
+
96
+ #needsMcpConfig() {
97
+ return this.#config.agentCommand.includes('{mcp_config}');
98
+ }
99
+
100
+ /**
101
+ * Подключение к чату под токеном ЭТОГО агента. Файлом, а не аргументом: токен в командной
102
+ * строке виден любому процессу через ps. Права 600, удаляется после запуска.
103
+ */
104
+ #writeMcpConfig() {
105
+ const { handle, mcpName, mcpUrl, token } = this.#config;
106
+ const file = path.join(os.tmpdir(), `chat-mcp-${handle}-${crypto.randomBytes(6).toString('hex')}.json`);
107
+ fs.writeFileSync(file, JSON.stringify({
108
+ mcpServers: { [mcpName]: { type: 'http', url: mcpUrl, headers: { Authorization: `Bearer ${token}` } } },
109
+ }), { mode: 0o600, flag: 'wx' });
110
+ return file;
111
+ }
112
+ }
113
+
114
+ function spawned(child) {
115
+ return new Promise((resolve, reject) => {
116
+ child.once('spawn', resolve);
117
+ child.once('error', reject);
118
+ });
119
+ }
120
+
121
+ /** Код возврата; убитый сигналом — отрицательный номер сигнала, как у Python. */
122
+ function exited(child) {
123
+ return new Promise(resolve => {
124
+ child.once('close', (code, signal) => resolve(code ?? -(os.constants.signals[signal] ?? 1)));
125
+ });
126
+ }