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,87 @@
1
+ // Точка входа моста.
2
+ //
3
+ // CHAT_TOKEN=… node host/bridge/main.js один проход и выход
4
+ // CHAT_TOKEN=… node host/bridge/main.js --watch дежурство, по умолчанию раз в 10 с
5
+ // CHAT_TOKEN=… node host/bridge/main.js --dry-run показать задание, не тратя токенов
6
+ //
7
+ // Остальные настройки — переменные CHAT_* (см. config.js).
8
+
9
+ import { parseArgs } from 'node:util';
10
+ import { ChatUnavailableError } from '../lib/chat-api.js';
11
+ import { log } from '../lib/log.js';
12
+ import { LockBusyError, PidLock } from '../lib/pid-lock.js';
13
+ import { createBridge } from './bridge.js';
14
+ import { bridgeConfig, ConfigError } from './config.js';
15
+
16
+ const { values: flags } = parseArgs({
17
+ options: {
18
+ watch: { type: 'boolean', default: false },
19
+ interval: { type: 'string' },
20
+ 'dry-run': { type: 'boolean', default: false },
21
+ },
22
+ });
23
+ const dryRun = flags['dry-run'];
24
+
25
+ let config;
26
+ try {
27
+ config = bridgeConfig(process.env, flags.interval ? { intervalMs: seconds(flags.interval) * 1000 } : {});
28
+ } catch (e) {
29
+ if (!(e instanceof ConfigError)) throw e;
30
+ exit(e.message);
31
+ }
32
+
33
+ // Показ задания агента не запускает — ему замок не нужен.
34
+ if (!dryRun) holdLock(config.lockFile);
35
+ for (const signal of ['SIGINT', 'SIGTERM']) {
36
+ process.on(signal, () => {
37
+ log('остановлен');
38
+ process.exit(0);
39
+ });
40
+ }
41
+
42
+ const bridge = createBridge(config, { log });
43
+
44
+ if (!flags.watch) {
45
+ if (!await pass()) process.exitCode = 1;
46
+ } else {
47
+ log(`дежурство: проверка раз в ${config.intervalMs / 1000} с. Холостой проход не стоит токенов. Ctrl+C — выход.`);
48
+ for (;;) {
49
+ // Дежурство не должно умирать из-за одного плохого прохода.
50
+ if (!await pass()) log(`повтор через ${config.intervalMs / 1000} с`);
51
+ await new Promise(resolve => setTimeout(resolve, config.intervalMs));
52
+ }
53
+ }
54
+
55
+ /** Проход с разбором сбоя. → удался ли. */
56
+ async function pass() {
57
+ try {
58
+ await bridge.cycle({ dryRun });
59
+ return true;
60
+ } catch (e) {
61
+ log(e instanceof ChatUnavailableError ? e.message : `ошибка прохода: ${e.stack}`);
62
+ return false;
63
+ }
64
+ }
65
+
66
+ /** Один мост на участника — иначе агент запускался бы дважды на одно обращение. */
67
+ function holdLock(file) {
68
+ let lock;
69
+ try {
70
+ lock = PidLock.acquire(file);
71
+ } catch (e) {
72
+ if (!(e instanceof LockBusyError)) throw e;
73
+ exit(`мост @${config.handle} уже запущен (pid ${e.pid}) — второй экземпляр запускал бы агента дважды`);
74
+ }
75
+ process.on('exit', () => lock.release());
76
+ }
77
+
78
+ function seconds(raw) {
79
+ const value = Number(raw);
80
+ if (!Number.isInteger(value) || value < 1) exit(`--interval ${raw} — нужно целое число секунд`);
81
+ return value;
82
+ }
83
+
84
+ function exit(message) {
85
+ console.error(message);
86
+ process.exit(1);
87
+ }
@@ -0,0 +1,74 @@
1
+ // Ход обработки обращений на сервер и сигнал жизни, пока агент работает.
2
+ //
3
+ // О работе сообщает МОСТ, а не агент: агент не может отметиться о своём падении —
4
+ // в этот момент его уже нет. Мост же видит и создание процесса, и код возврата.
5
+ //
6
+ // «Ответ опубликован» сюда не входит и входить не может: его сервер выводит сам,
7
+ // по сообщению с reply_to_id конкретного обращения.
8
+
9
+ export class ProgressReporter {
10
+ #api;
11
+ #log;
12
+
13
+ constructor(api, log) {
14
+ this.#api = api;
15
+ this.#log = log;
16
+ }
17
+
18
+ /**
19
+ * Сбой индикатора не должен ронять ответ: чат без «работает» живёт, ответ без чата —
20
+ * нет. Поэтому ошибка здесь только пишется в журнал.
21
+ *
22
+ * @param {number[]} mentionIds
23
+ * @param {'working'|'done'|'failed'|'deferred'|'paused'} state
24
+ */
25
+ async report(mentionIds, state, { note, ttl } = {}) {
26
+ try {
27
+ await this.#api.report({ state, mentions: mentionIds, note, ttl });
28
+ return true;
29
+ } catch (e) {
30
+ this.#log(`индикатор: не удалось отметить ${state} по ${mentionIds.join(', ')}: ${e.message}`);
31
+ return false;
32
+ }
33
+ }
34
+ }
35
+
36
+ /**
37
+ * «Работает» по обращениям запуска — с продлением, пока агент жив. Пропажу сигнала сервер
38
+ * судит по сроку годности, так что упавший мост гасит индикатор сам.
39
+ */
40
+ export class Beacon {
41
+ #reporter;
42
+ #mentionIds;
43
+ #note;
44
+ #ttl;
45
+ #intervalMs;
46
+ #timer = null;
47
+ #inFlight = Promise.resolve();
48
+
49
+ constructor(reporter, { mentionIds, note, ttl, intervalMs }) {
50
+ this.#reporter = reporter;
51
+ this.#mentionIds = mentionIds;
52
+ this.#note = note;
53
+ this.#ttl = ttl;
54
+ this.#intervalMs = intervalMs;
55
+ }
56
+
57
+ /** Только после того, как процесс агента создан: не запустившаяся команда «работает» не показывает. */
58
+ start() {
59
+ this.#beat();
60
+ this.#timer = setInterval(() => this.#beat(), this.#intervalMs);
61
+ }
62
+
63
+ /** Дожидается последнего удара: иначе «работает» могло бы прийти на сервер позже исхода. */
64
+ async stop() {
65
+ clearInterval(this.#timer);
66
+ this.#timer = null;
67
+ await this.#inFlight;
68
+ }
69
+
70
+ #beat() {
71
+ this.#inFlight = this.#inFlight.then(() =>
72
+ this.#reporter.report(this.#mentionIds, 'working', { note: this.#note, ttl: this.#ttl }));
73
+ }
74
+ }
@@ -0,0 +1,97 @@
1
+ // Задание агенту. Чистая функция: всё, что нужно, приходит аргументами — файлы читает
2
+ // тот, кто её зовёт.
3
+ //
4
+ // Инструкций про API, токены и формат запроса здесь нет: агент подключён к чату по MCP
5
+ // и знает свои инструменты сам. Чем короче задание, тем дешевле каждый запуск и тем
6
+ // меньше способов ошибиться.
7
+
8
+ /**
9
+ * Все обращения одного прохода в одном чате — в ОДНО задание: три сообщения подряд,
10
+ * написанные, пока агент думает над первым, не должны стоить трёх запусков.
11
+ *
12
+ * @param {object} p
13
+ * @param {object[]} p.mentions обращения одного чата, как их отдаёт /api/mentions
14
+ * @param {object|null} p.chat настройки чата из /api/chats: name, slug, workdir, description, team
15
+ * @param {string} p.handle кто мы
16
+ * @param {string} p.rules общие правила чата (agents/rules.md)
17
+ * @param {string} p.runtimeNotes особенности движка (agents/runtimes/<движок>.md) или ''
18
+ * @param {string} p.mcpName имя MCP-сервера чата у агента
19
+ * @param {object[]} [p.experience] уроки прошлых похожих задач, как их отдаёт /api/knowledge
20
+ */
21
+ export function buildPrompt({ mentions, chat, handle, rules, runtimeNotes, mcpName, experience = [] }) {
22
+ const plural = mentions.length > 1 ? 'обращения' : 'обращение';
23
+ const ids = mentions.map(m => `#${m.message.id}`).join(', ');
24
+
25
+ return `${rules}\n\n`
26
+ + (runtimeNotes ? `${runtimeNotes.trim()}\n\n` : '')
27
+ + '---\n\n'
28
+ + whoAmI(handle, mentions[0].role ?? {}, chat?.team ?? []) + '\n'
29
+ + aboutProject(chat)
30
+ + `Тебя упомянули в чате. Необработанных ${plural}: ${mentions.length} (${ids}).\n\n`
31
+ + mentions.map(m => messageBlock(m.message)).join('\n\n')
32
+ + experienceBlock(experience)
33
+ + '\n\n---\n\n'
34
+ + `Инструменты чата у тебя есть (MCP-сервер ${mcpName}): read-thread для контекста, `
35
+ + 'post-message для ответа, search-thread для старых обсуждений, '
36
+ + 'read-attachment для приложенных файлов, find-knowledge для опыта команды в похожих задачах.\n'
37
+ + 'На КАЖДОЕ перечисленное сообщение ответь отдельным post-message со своим reply_to_id — '
38
+ + 'иначе обращение останется висеть.\n'
39
+ + 'Обращения не подтверждай: это сделает мост, убедившись, что ответ действительно опубликован.';
40
+ }
41
+
42
+ /** Роль в этом чате и состав команды: в тексте роли имён нет — команда меняется. */
43
+ function whoAmI(handle, role, team) {
44
+ let text = `Ты — @${handle}`;
45
+ if (role.name) text += `, роль в этом чате — «${role.name}»`;
46
+ text += '.\n';
47
+ if (role.instructions) text += `\n${role.instructions.trim()}\n`;
48
+ if (team.length) {
49
+ text += '\nКоманда этого чата:\n'
50
+ + team.map(t => ` • @${t.handle} — ${t.name}, ${t.role_name}${t.handle === handle ? ' (это ты)' : ''}`).join('\n')
51
+ + '\n';
52
+ }
53
+ return text;
54
+ }
55
+
56
+ /** Что за проект — из настроек чата: инструкции агента про роль, а проектов много. */
57
+ function aboutProject(chat) {
58
+ if (!chat) return '';
59
+ return `Чат «${chat.name}» (chat: ${chat.slug}). Передавай chat=${chat.slug} `
60
+ + 'инструментам чата — у каждого проекта свой тред, журнал решений и документы.\n'
61
+ + (chat.workdir ? `Код проекта: ${chat.workdir}\n` : '')
62
+ + (chat.description ? `\nО проекте:\n${chat.description.trim()}\n` : '')
63
+ + '\n';
64
+ }
65
+
66
+ /**
67
+ * Уроки прошлых запусков, похожие на эти обращения. Подсказка, а не правило: задача может
68
+ * только казаться похожей, поэтому — с оговоркой.
69
+ */
70
+ function experienceBlock(lessons) {
71
+ if (!lessons.length) return '';
72
+ return '\n\nОпыт команды — уроки из похожих задач (сверь с задачей, прежде чем применять):\n'
73
+ + lessons.map(({ problem, solution }) => {
74
+ let text = ` • ${solution.rule}`;
75
+ const was = [problem?.symptom, problem?.root_cause && `причина — ${problem.root_cause}`].filter(Boolean).map(clause);
76
+ if (was.length) text += `\n Было: ${was.join('; ')}.`;
77
+ if (solution.applies_when) text += `\n Когда применять: ${clause(solution.applies_when)}.`;
78
+ return text;
79
+ }).join('\n');
80
+ }
81
+
82
+ /** Фраза внутри предложения: без точки на конце — её поставит предложение. */
83
+ function clause(text) {
84
+ return String(text).trim().replace(/[.\s]+$/, '');
85
+ }
86
+
87
+ function messageBlock(message) {
88
+ let block = `Сообщение #${message.id} от ${message.author.name} (${message.author.role}):\n${message.body}`;
89
+ // Файл часто и есть вопрос («что тут не так?» + скриншот) — агент должен знать,
90
+ // что он есть, ещё до первого вызова инструментов.
91
+ const files = message.attachments ?? [];
92
+ if (files.length) {
93
+ block += '\nВложения (открыть — read-attachment по id):\n'
94
+ + files.map(a => ` • id ${a.id}: ${a.name} (${a.mime}, ${a.size} байт)`).join('\n');
95
+ }
96
+ return block;
97
+ }
@@ -0,0 +1,200 @@
1
+ // Записи запусков агента — сырьё для своего агента владельца.
2
+ //
3
+ // В треде остаётся только итоговый ответ, а ценное — ход работы: что агент читал,
4
+ // какие команды запускал, где ошибся и как поправился. Его движки отдают в stdout
5
+ // построчным JSON (claude --output-format stream-json, codex exec --json). Мост кладёт
6
+ // каждый запуск в свой каталог:
7
+ //
8
+ // <runsDir>/<чат>/<дата UTC>/<время>-<handle>-<обращения>/
9
+ // run.json что за запуск и чем кончился — по каждому обращению (формат ниже)
10
+ // prompt.md задание, ушедшее агенту
11
+ // stdout ход работы агента — как есть
12
+ // stderr ошибки движка
13
+ // card.json карточка опыта — что из запуска остаётся надолго (host/knowledge/card.js)
14
+ //
15
+ // run.json пишется дважды: при запуске и по итогам. Запуск, оборвавшийся вместе с мостом,
16
+ // остаётся с finished_at: null — тоже факт о работе агента.
17
+ //
18
+ // Сырьё (prompt.md, stdout, stderr) живёт ограниченный срок — его чистит раннер
19
+ // (pruneRuns ниже); card.json и run.json остаются: в них опыт, а не содержимое проекта.
20
+ //
21
+ // Внутри — чужой код и переписка: каталоги 700, файлы 600. Команда запуска целиком не
22
+ // сохраняется: runner.json — настройки машины, в нём может оказаться что угодно.
23
+
24
+ import fs from 'node:fs';
25
+ import path from 'node:path';
26
+
27
+ /** Версия формата run.json: записи будут переезжать в базу, и разбирать их надо уметь. */
28
+ export const FORMAT = 1;
29
+
30
+ export class RunArchive {
31
+ #dir;
32
+ #now;
33
+
34
+ /** @param {string} dir корень записей (CHAT_RUNS_DIR) */
35
+ constructor(dir, { now = () => new Date() } = {}) {
36
+ this.#dir = dir;
37
+ this.#now = now;
38
+ }
39
+
40
+ /**
41
+ * Завести запись запуска — до создания процесса: задание сохранено, даже если агент
42
+ * не запустится.
43
+ *
44
+ * @param {object} p
45
+ * @param {string} p.handle
46
+ * @param {string} p.runtime движок
47
+ * @param {string} p.executable чем запускали — первое слово команды, без аргументов
48
+ * @param {object|null} p.chat настройки чата
49
+ * @param {object[]} p.mentions обращения запуска
50
+ * @param {string} p.prompt
51
+ * @param {string|null} p.cwd
52
+ */
53
+ begin({ handle, runtime, executable, chat, mentions, prompt, cwd }) {
54
+ const started = this.#now();
55
+ const stamp = started.toISOString();
56
+ const dir = path.join(
57
+ this.#dir,
58
+ safe(chat?.slug ?? 'default'),
59
+ stamp.slice(0, 10),
60
+ `${stamp.slice(11, 23).replaceAll(':', '')}-${safe(handle)}-${mentions.map(m => m.id).join('-')}`,
61
+ );
62
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
63
+
64
+ const record = new RunRecord(dir, path.relative(this.#dir, dir).split(path.sep).join('/'), this.#now, started, {
65
+ format: FORMAT,
66
+ handle,
67
+ runtime: runtime || null,
68
+ executable: path.basename(executable),
69
+ chat: chat ? { slug: chat.slug, name: chat.name } : null,
70
+ cwd,
71
+ started_at: stamp,
72
+ finished_at: null,
73
+ duration_ms: null,
74
+ exit_code: null,
75
+ mentions: mentions.map(m => ({
76
+ mention_id: m.id,
77
+ message_id: m.message.id,
78
+ author: { handle: m.message.author.handle ?? null, role: m.message.author.role },
79
+ agent_role: m.role?.slug ?? null,
80
+ attempt: null,
81
+ outcome: null,
82
+ answer_message_id: null,
83
+ })),
84
+ });
85
+ writePrivate(path.join(dir, 'prompt.md'), prompt);
86
+ record.save();
87
+ return record;
88
+ }
89
+ }
90
+
91
+ /** Запись одного запуска. Заполняется по ходу, сохраняется save(). */
92
+ export class RunRecord {
93
+ #dir;
94
+ #key;
95
+ #now;
96
+ #data;
97
+ #started;
98
+
99
+ constructor(dir, key, now, started, data) {
100
+ this.#dir = dir;
101
+ this.#key = key;
102
+ this.#now = now;
103
+ this.#started = started;
104
+ this.#data = data;
105
+ }
106
+
107
+ get dir() {
108
+ return this.#dir;
109
+ }
110
+
111
+ /** Имя записи внутри корня записей: «чат/дата/время-handle-обращения». Им же карточка зовётся на сервере. */
112
+ get key() {
113
+ return this.#key;
114
+ }
115
+
116
+ get cardFile() {
117
+ return path.join(this.#dir, 'card.json');
118
+ }
119
+
120
+ /** Куда агенту писать вывод. */
121
+ get output() {
122
+ return { stdout: path.join(this.#dir, 'stdout'), stderr: path.join(this.#dir, 'stderr') };
123
+ }
124
+
125
+ /** @param {number|null} exitCode null — процесс не создан */
126
+ finish(exitCode) {
127
+ const finished = this.#now();
128
+ this.#data.finished_at = finished.toISOString();
129
+ this.#data.duration_ms = finished - this.#started;
130
+ this.#data.exit_code = exitCode;
131
+ }
132
+
133
+ /**
134
+ * Исход по обращению.
135
+ * @param {number} mentionId
136
+ * @param {{outcome: 'answered'|'done'|'failed'|'deferred', attempt: number, answerMessageId?: number|null}} result
137
+ */
138
+ settle(mentionId, { outcome, attempt, answerMessageId = null }) {
139
+ const mention = this.#data.mentions.find(m => m.mention_id === mentionId);
140
+ Object.assign(mention, { outcome, attempt, answer_message_id: answerMessageId });
141
+ }
142
+
143
+ save() {
144
+ writePrivate(path.join(this.#dir, 'run.json'), JSON.stringify(this.#data, null, 2) + '\n');
145
+ }
146
+
147
+ /** Сведения о запуске — то, что лежит в run.json. */
148
+ get data() {
149
+ return structuredClone(this.#data);
150
+ }
151
+
152
+ attachCard(card) {
153
+ writePrivate(this.cardFile, JSON.stringify(card, null, 2) + '\n');
154
+ }
155
+ }
156
+
157
+ /** Что остаётся от записи после срока хранения: опыт и сведения о запуске, без содержимого. */
158
+ const KEPT = new Set(['card.json', 'run.json']);
159
+
160
+ /**
161
+ * Удалить сырьё записей старше keepDays: задание, ход работы, ошибки. Возраст — по дате в
162
+ * пути (<чат>/<дата UTC>/…), читать каждую запись незачем. Повторный проход ничего не ломает.
163
+ *
164
+ * @param {string} root корень записей (CHAT_RUNS_DIR)
165
+ * @param {{keepDays: number, now?: Date}} options
166
+ * @returns {number} у скольких запусков удалено сырьё
167
+ */
168
+ export function pruneRuns(root, { keepDays, now = new Date() }) {
169
+ const cutoff = new Date(now.getTime() - keepDays * 86_400_000).toISOString().slice(0, 10);
170
+ let pruned = 0;
171
+ for (const chat of subdirs(root)) {
172
+ for (const day of subdirs(path.join(root, chat))) {
173
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(day) || day >= cutoff) continue;
174
+ for (const run of subdirs(path.join(root, chat, day))) {
175
+ const dir = path.join(root, chat, day, run);
176
+ const raw = fs.readdirSync(dir, { withFileTypes: true }).filter(f => f.isFile() && !KEPT.has(f.name));
177
+ for (const file of raw) fs.rmSync(path.join(dir, file.name));
178
+ if (raw.length) pruned++;
179
+ }
180
+ }
181
+ }
182
+ return pruned;
183
+ }
184
+
185
+ function subdirs(dir) {
186
+ try {
187
+ return fs.readdirSync(dir, { withFileTypes: true }).filter(d => d.isDirectory()).map(d => d.name).sort();
188
+ } catch {
189
+ return []; // записей ещё нет
190
+ }
191
+ }
192
+
193
+ /** Имя для пути: slug и handle приходят с сервера — без «..» и разделителей. */
194
+ function safe(name) {
195
+ return String(name).replace(/[^\w.-]/g, '_').replace(/^\.+/, '_') || '_';
196
+ }
197
+
198
+ function writePrivate(file, text) {
199
+ fs.writeFileSync(file, text, { mode: 0o600 });
200
+ }
@@ -0,0 +1,44 @@
1
+ // Вопросы моста к ленте чата: ответил ли агент и сколько агенты наговорили без владельца.
2
+
3
+ /** Страница ленты — максимум, который отдаёт сервер (ChatController::limit). */
4
+ export const PAGE = 500;
5
+
6
+ export class Thread {
7
+ #api;
8
+ #handle;
9
+
10
+ constructor(api, handle) {
11
+ this.#api = api;
12
+ this.#handle = handle;
13
+ }
14
+
15
+ /**
16
+ * Отвечал ли я ИМЕННО на это сообщение — по reply_to_id, а не «есть ответ позже».
17
+ *
18
+ * Ленту после вопроса листаем до конца: ответ, оказавшийся за границей страницы,
19
+ * иначе выглядел бы как «ответа нет» — и мост запустил бы агента второй раз.
20
+ */
21
+ async answered(message) {
22
+ return (await this.answerTo(message)) !== null;
23
+ }
24
+
25
+ /** Мой ответ на это сообщение или null. */
26
+ async answerTo(message) {
27
+ const chat = message.chat?.slug ?? null;
28
+ let after = message.id;
29
+ for (;;) {
30
+ const page = await this.#api.messages(chat, { afterId: after, limit: PAGE });
31
+ const answer = page.find(m => m.author.handle === this.#handle && m.reply_to_id === message.id);
32
+ if (answer) return answer;
33
+ if (page.length < PAGE) return null;
34
+ after = page.at(-1).id;
35
+ }
36
+ }
37
+
38
+ /** Сколько раз агенты высказались после последней реплики владельца — в этом чате. */
39
+ async agentTurnsSinceOwner(chat) {
40
+ const recent = await this.#api.messages(chat, { limit: 100 });
41
+ const lastOwner = recent.findLastIndex(m => m.author.role === 'owner');
42
+ return recent.slice(lastOwner + 1).filter(m => m.author.kind === 'ai').length;
43
+ }
44
+ }
@@ -0,0 +1,97 @@
1
+ // Разбор обращения до запуска агента — цепочка правил. Каждое правило отвечает на один
2
+ // вопрос и либо выносит вердикт, либо передаёт обращение дальше. Дошло до конца —
3
+ // агента надо запускать.
4
+ //
5
+ // Правило только решает; исполняет вердикт мост. Так каждое правило проверяется
6
+ // без сети и без процессов, а все побочные эффекты — в одном месте.
7
+
8
+ /** Погасить обращение без запуска. reason — для журнала. */
9
+ export const acknowledge = (reason, { forget = false } = {}) => ({ kind: 'acknowledge', reason, forget });
10
+
11
+ /** Не запускать и не гасить: показать на странице, почему обращение ждёт (note). */
12
+ export const hold = (state, note, reason = null) => ({ kind: 'hold', state, note, reason });
13
+
14
+ export const RUN = Object.freeze({ kind: 'run' });
15
+
16
+ /**
17
+ * @typedef {object} TriageContext
18
+ * @property {import('./attempts.js').AttemptStore} attempts
19
+ * @property {(message: object) => Promise<boolean>} answered
20
+ * @property {(chat: string|null) => Promise<number>} turnsInChat ходы агентов после владельца
21
+ *
22
+ * @typedef {(mention: object, context: TriageContext) => Promise<object|null>|object|null} Rule
23
+ */
24
+
25
+ /** Порядок важен: дешёвые и окончательные вердикты — раньше. */
26
+ export function triageRules({ triggerRoles, maxAttempts, turnBudget }) {
27
+ return [
28
+ fromTriggerRole(triggerRoles),
29
+ notAnsweredYet(),
30
+ withinAttempts(maxAttempts),
31
+ withinTurnBudget(turnBudget),
32
+ ];
33
+ }
34
+
35
+ /** @param {Rule[]} rules */
36
+ export async function triage(mention, rules, context) {
37
+ for (const rule of rules) {
38
+ const verdict = await rule(mention, context);
39
+ if (verdict) return verdict;
40
+ }
41
+ return RUN;
42
+ }
43
+
44
+ export function deferredNote(attempts, maxAttempts, last = null) {
45
+ return `попыток ${attempts} из ${maxAttempts}${last ? `, последняя: ${last}` : ''} — автоматического запуска не будет`;
46
+ }
47
+
48
+ /**
49
+ * Обращение от того, чьи сообщения агента не поднимают (по умолчанию — от другого
50
+ * агента), остаётся в треде как контекст, но в очереди ему не место: очередь отвечает
51
+ * на вопрос «что требует ответа», а не «где меня упомянули». Гасим сразу, иначе такие
52
+ * обращения копятся и прячут настоящие.
53
+ */
54
+ function fromTriggerRole(roles) {
55
+ return mention => {
56
+ const role = mention.message.author.role;
57
+ return roles.includes('any') || roles.includes(role)
58
+ ? null
59
+ : acknowledge(`сообщение от ${role} — контекст, ответ не требуется; подтверждено`);
60
+ };
61
+ }
62
+
63
+ /** Ответ опубликован, а подтвердить мост не успел: повторный запуск дал бы дубль. */
64
+ function notAnsweredYet() {
65
+ return async (mention, { answered }) => (await answered(mention.message)
66
+ ? acknowledge('ответ уже в треде — подтверждено без запуска агента', { forget: true })
67
+ : null);
68
+ }
69
+
70
+ /**
71
+ * Попытки исчерпаны: не долбим по кругу, вопрос остаётся в очереди. Но страница должна
72
+ * это видеть, а не показывать «ожидает запуска» того, чего не будет. Повтор каждый проход
73
+ * идемпотентен: сервер не сдвигает время исхода.
74
+ */
75
+ function withinAttempts(maxAttempts) {
76
+ return (mention, { attempts }) => {
77
+ const count = attempts.count(mention.id);
78
+ return count >= maxAttempts ? hold('deferred', deferredNote(count, maxAttempts)) : null;
79
+ };
80
+ }
81
+
82
+ /**
83
+ * Обращение от другого агента тратит бюджет разговора, обращение владельца — нет: его
84
+ * реплика бюджет обнуляет, поэтому он всегда может нас позвать. Исчерпан — НЕ гасим:
85
+ * это отложено до слова владельца, а не «не требует ответа».
86
+ */
87
+ function withinTurnBudget(turnBudget) {
88
+ return async (mention, { turnsInChat }) => {
89
+ const { role } = mention.message.author;
90
+ if (role === 'owner') return null;
91
+ const turns = await turnsInChat(mention.message.chat?.slug ?? null);
92
+ return turns >= turnBudget
93
+ ? hold('paused', `ходов агентов после реплики владельца: ${turns} из ${turnBudget}`,
94
+ `сообщение от ${role}, бюджет разговора исчерпан (${turns} из ${turnBudget}) — ждём слова владельца`)
95
+ : null;
96
+ };
97
+ }
@@ -0,0 +1,41 @@
1
+ // Где запускать агента. Каталог проекта присылает сервер, поэтому ему не верим на слово:
2
+ // сервер не должен уметь отправить агента в произвольный каталог машины.
3
+
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+
7
+ /**
8
+ * @param {object|null} chat настройки чата (workdir — каталог проекта)
9
+ * @param {{agentCwd: string, workdirRoot: string}} config
10
+ * @returns {string|null} null — запускать в каталоге моста
11
+ */
12
+ export function resolveAgentCwd(chat, { agentCwd, workdirRoot }, log) {
13
+ // Каталог машины главнее каталога проекта: у Codex это песочница.
14
+ if (agentCwd) return agentCwd;
15
+
16
+ const workdir = chat?.workdir;
17
+ if (!workdir) return null;
18
+
19
+ const dir = realDirectory(workdir);
20
+ const root = realDirectory(workdirRoot) ?? path.resolve(workdirRoot);
21
+ if (!dir || !isInside(dir, root)) {
22
+ log(`каталог проекта ${workdir} не годится (нет такого или вне ${root}) — запускаю без него`);
23
+ return null;
24
+ }
25
+ return dir;
26
+ }
27
+
28
+ /** Настоящий путь каталога — после ссылок: ссылка внутри корня может вести наружу. */
29
+ function realDirectory(dir) {
30
+ try {
31
+ const real = fs.realpathSync(dir);
32
+ return fs.statSync(real).isDirectory() ? real : null;
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ function isInside(dir, root) {
39
+ const relative = path.relative(root, dir);
40
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
41
+ }