tycono-server 0.1.0-beta.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 (84) hide show
  1. package/bin/cli.js +35 -0
  2. package/bin/server.ts +160 -0
  3. package/package.json +50 -0
  4. package/src/api/package.json +31 -0
  5. package/src/api/src/create-app.ts +90 -0
  6. package/src/api/src/create-server.ts +251 -0
  7. package/src/api/src/engine/agent-loop.ts +738 -0
  8. package/src/api/src/engine/authority-validator.ts +149 -0
  9. package/src/api/src/engine/context-assembler.ts +912 -0
  10. package/src/api/src/engine/index.ts +27 -0
  11. package/src/api/src/engine/knowledge-gate.ts +365 -0
  12. package/src/api/src/engine/llm-adapter.ts +304 -0
  13. package/src/api/src/engine/org-tree.ts +270 -0
  14. package/src/api/src/engine/role-lifecycle.ts +369 -0
  15. package/src/api/src/engine/runners/claude-cli.ts +796 -0
  16. package/src/api/src/engine/runners/direct-api.ts +66 -0
  17. package/src/api/src/engine/runners/index.ts +30 -0
  18. package/src/api/src/engine/runners/types.ts +95 -0
  19. package/src/api/src/engine/skill-template.ts +134 -0
  20. package/src/api/src/engine/tools/definitions.ts +201 -0
  21. package/src/api/src/engine/tools/executor.ts +611 -0
  22. package/src/api/src/routes/active-sessions.ts +134 -0
  23. package/src/api/src/routes/coins.ts +153 -0
  24. package/src/api/src/routes/company.ts +57 -0
  25. package/src/api/src/routes/cost.ts +141 -0
  26. package/src/api/src/routes/engine.ts +220 -0
  27. package/src/api/src/routes/execute.ts +1075 -0
  28. package/src/api/src/routes/git.ts +211 -0
  29. package/src/api/src/routes/knowledge.ts +378 -0
  30. package/src/api/src/routes/operations.ts +309 -0
  31. package/src/api/src/routes/preferences.ts +63 -0
  32. package/src/api/src/routes/presets.ts +123 -0
  33. package/src/api/src/routes/projects.ts +82 -0
  34. package/src/api/src/routes/quests.ts +41 -0
  35. package/src/api/src/routes/roles.ts +112 -0
  36. package/src/api/src/routes/save.ts +152 -0
  37. package/src/api/src/routes/sessions.ts +288 -0
  38. package/src/api/src/routes/setup.ts +437 -0
  39. package/src/api/src/routes/skills.ts +357 -0
  40. package/src/api/src/routes/speech.ts +959 -0
  41. package/src/api/src/routes/supervision.ts +136 -0
  42. package/src/api/src/routes/sync.ts +165 -0
  43. package/src/api/src/server.ts +59 -0
  44. package/src/api/src/services/activity-stream.ts +184 -0
  45. package/src/api/src/services/activity-tracker.ts +115 -0
  46. package/src/api/src/services/claude-md-manager.ts +94 -0
  47. package/src/api/src/services/company-config.ts +115 -0
  48. package/src/api/src/services/database.ts +77 -0
  49. package/src/api/src/services/digest-engine.ts +313 -0
  50. package/src/api/src/services/execution-manager.ts +1036 -0
  51. package/src/api/src/services/file-reader.ts +77 -0
  52. package/src/api/src/services/git-save.ts +614 -0
  53. package/src/api/src/services/job-manager.ts +16 -0
  54. package/src/api/src/services/knowledge-importer.ts +466 -0
  55. package/src/api/src/services/markdown-parser.ts +173 -0
  56. package/src/api/src/services/port-registry.ts +222 -0
  57. package/src/api/src/services/preferences.ts +150 -0
  58. package/src/api/src/services/preset-loader.ts +149 -0
  59. package/src/api/src/services/pricing.ts +34 -0
  60. package/src/api/src/services/scaffold.ts +546 -0
  61. package/src/api/src/services/session-store.ts +340 -0
  62. package/src/api/src/services/supervisor-heartbeat.ts +897 -0
  63. package/src/api/src/services/team-recommender.ts +382 -0
  64. package/src/api/src/services/token-ledger.ts +127 -0
  65. package/src/api/src/services/wave-messages.ts +194 -0
  66. package/src/api/src/services/wave-multiplexer.ts +356 -0
  67. package/src/api/src/services/wave-tracker.ts +359 -0
  68. package/src/api/src/utils/role-level.ts +31 -0
  69. package/src/core/scaffolder.ts +620 -0
  70. package/src/shared/types.ts +224 -0
  71. package/templates/CLAUDE.md.tmpl +239 -0
  72. package/templates/company.md.tmpl +17 -0
  73. package/templates/gitignore.tmpl +28 -0
  74. package/templates/roles.md.tmpl +8 -0
  75. package/templates/skills/_manifest.json +23 -0
  76. package/templates/skills/agent-browser/SKILL.md +159 -0
  77. package/templates/skills/agent-browser/meta.json +19 -0
  78. package/templates/skills/akb-linter/SKILL.md +125 -0
  79. package/templates/skills/akb-linter/meta.json +12 -0
  80. package/templates/skills/knowledge-gate/SKILL.md +120 -0
  81. package/templates/skills/knowledge-gate/meta.json +12 -0
  82. package/templates/teams/agency.json +58 -0
  83. package/templates/teams/research.json +58 -0
  84. package/templates/teams/startup.json +58 -0
@@ -0,0 +1,94 @@
1
+ /**
2
+ * claude-md-manager.ts — CLAUDE.md lifecycle management
3
+ *
4
+ * CLAUDE.md is 100% Tycono-managed. This module handles:
5
+ * - Version tracking via .tycono/rules-version
6
+ * - Auto-regeneration on version mismatch (server startup)
7
+ * - Backup of pre-existing CLAUDE.md (first time only)
8
+ * - Stub creation for knowledge/custom-rules.md
9
+ */
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+
14
+ const __filename = fileURLToPath(import.meta.url);
15
+ const __dirname = path.dirname(__filename);
16
+ const TEMPLATES_DIR = path.resolve(__dirname, '../../../../templates');
17
+
18
+ /**
19
+ * Read the current package version from package.json
20
+ */
21
+ function getPackageVersion(): string {
22
+ const pkgPath = path.resolve(__dirname, '../../../../package.json');
23
+ try {
24
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
25
+ return pkg.version || '0.0.0';
26
+ } catch {
27
+ return '0.0.0';
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Generate CLAUDE.md content from template with version marker
33
+ */
34
+ function generateClaudeMd(version: string): string {
35
+ const tmplPath = path.join(TEMPLATES_DIR, 'CLAUDE.md.tmpl');
36
+ const template = fs.readFileSync(tmplPath, 'utf-8');
37
+ return template.replaceAll('{{VERSION}}', version);
38
+ }
39
+
40
+ /**
41
+ * Ensure CLAUDE.md is up-to-date with the current package version.
42
+ *
43
+ * Called on server startup. Compares .tycono/rules-version with package version.
44
+ * If different, regenerates CLAUDE.md from template (safe because CLAUDE.md
45
+ * contains 0% user data — all user customization is in knowledge/custom-rules.md).
46
+ */
47
+ export function ensureClaudeMd(companyRoot: string): void {
48
+ const tyconoDir = path.join(companyRoot, '.tycono');
49
+ const rulesVersionPath = path.join(tyconoDir, 'rules-version');
50
+ const claudeMdPath = path.join(companyRoot, 'knowledge', 'CLAUDE.md');
51
+ const knowledgeDir = path.join(companyRoot, 'knowledge');
52
+ const customRulesPath = path.join(knowledgeDir, 'custom-rules.md');
53
+ const backupPath = path.join(tyconoDir, 'CLAUDE.md.backup');
54
+
55
+ // Skip if not initialized (no .tycono/ directory)
56
+ if (!fs.existsSync(tyconoDir)) return;
57
+
58
+ const currentVersion = getPackageVersion();
59
+
60
+ // Read stored version
61
+ let storedVersion = '0.0.0';
62
+ if (fs.existsSync(rulesVersionPath)) {
63
+ storedVersion = fs.readFileSync(rulesVersionPath, 'utf-8').trim();
64
+ }
65
+
66
+ // Skip if already up-to-date
67
+ if (storedVersion === currentVersion) return;
68
+
69
+ // Backup existing CLAUDE.md (first time only — don't overwrite previous backup)
70
+ if (fs.existsSync(claudeMdPath) && !fs.existsSync(backupPath)) {
71
+ fs.copyFileSync(claudeMdPath, backupPath);
72
+ console.log(`[CLAUDE.md] Backed up existing CLAUDE.md to .tycono/CLAUDE.md.backup`);
73
+ }
74
+
75
+ // Regenerate CLAUDE.md from template
76
+ const content = generateClaudeMd(currentVersion);
77
+ fs.writeFileSync(claudeMdPath, content);
78
+
79
+ // Update rules-version
80
+ fs.writeFileSync(rulesVersionPath, currentVersion);
81
+
82
+ // Create custom-rules.md stub if not exists
83
+ if (!fs.existsSync(customRulesPath)) {
84
+ fs.writeFileSync(customRulesPath, `# Custom Rules
85
+
86
+ > Company-specific rules, constraints, and processes.
87
+ > This file is owned by you — Tycono will never overwrite it.
88
+
89
+ <!-- Add your custom rules below -->
90
+ `);
91
+ }
92
+
93
+ console.log(`[CLAUDE.md] System rules updated to v${currentVersion} (was v${storedVersion})`);
94
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * company-config.ts — .tycono/config.json 관리
3
+ *
4
+ * AKB 디렉토리의 영구 설정을 읽고 쓴다.
5
+ * scaffold 시 생성되고, 서버 시작 시 로드된다.
6
+ */
7
+ import { execSync } from 'node:child_process';
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+
11
+ export interface ConversationLimits {
12
+ /** Harness 레벨 경고 턴 수 (기본 50). 도달 시 turn:warning 이벤트 발생. */
13
+ softLimit: number;
14
+ /** Harness 레벨 강제 종료 턴 수 (기본 200). 도달 시 Runner abort. */
15
+ hardLimit: number;
16
+ }
17
+
18
+ export interface CompanyConfig {
19
+ engine: 'claude-cli' | 'direct-api';
20
+ model?: string;
21
+ apiKey?: string;
22
+ codeRoot?: string; // 코드 프로젝트 경로 (AKB와 분리된 코드 repo)
23
+ conversationLimits?: Partial<ConversationLimits>;
24
+ supervision?: {
25
+ mode: 'supervisor' | 'direct';
26
+ };
27
+ }
28
+
29
+ export const TYCONO_DIR = '.tycono';
30
+ const CONFIG_DIR = TYCONO_DIR;
31
+ const CONFIG_FILE = 'config.json';
32
+ const DEFAULT_CONVERSATION_LIMITS: ConversationLimits = {
33
+ softLimit: 50,
34
+ hardLimit: 200,
35
+ };
36
+
37
+ const DEFAULT_CONFIG: CompanyConfig = { engine: 'claude-cli' };
38
+
39
+ /** Resolve conversation limits with defaults. */
40
+ export function getConversationLimits(config: CompanyConfig): ConversationLimits {
41
+ return {
42
+ ...DEFAULT_CONVERSATION_LIMITS,
43
+ ...config.conversationLimits,
44
+ };
45
+ }
46
+
47
+ function configPath(companyRoot: string): string {
48
+ return path.join(companyRoot, CONFIG_DIR, CONFIG_FILE);
49
+ }
50
+
51
+ /**
52
+ * Resolve codeRoot: explicit config > auto-generated sibling directory.
53
+ * When codeRoot is not configured, defaults to `../{dirname}-code/` next to companyRoot.
54
+ * Auto-creates the directory if it doesn't exist.
55
+ */
56
+ export function resolveCodeRoot(companyRoot: string): string {
57
+ const config = readConfig(companyRoot);
58
+ const codeRoot = config.codeRoot ?? (() => {
59
+ // Auto-generate: ../{folder-name}-code/
60
+ const dirName = path.basename(companyRoot);
61
+ const auto = path.join(path.dirname(companyRoot), `${dirName}-code`);
62
+ if (!fs.existsSync(auto)) {
63
+ fs.mkdirSync(auto, { recursive: true });
64
+ }
65
+ // Persist so it's stable across restarts
66
+ writeConfig(companyRoot, { ...config, codeRoot: auto });
67
+ return auto;
68
+ })();
69
+
70
+ // Auto-init git if not already a repo (even if codeRoot was already configured)
71
+ const gitDir = path.join(codeRoot, '.git');
72
+ if (fs.existsSync(codeRoot) && !fs.existsSync(gitDir)) {
73
+ try {
74
+ execSync('git init', { cwd: codeRoot, stdio: 'pipe' });
75
+ execSync('git commit --allow-empty -m "Initial commit by Tycono"', { cwd: codeRoot, stdio: 'pipe' });
76
+ } catch { /* ignore — git may not be installed */ }
77
+ }
78
+
79
+ return codeRoot;
80
+ }
81
+
82
+ /** Read config from .tycono/config.json. Returns defaults if missing. */
83
+ export function readConfig(companyRoot: string): CompanyConfig {
84
+ const p = configPath(companyRoot);
85
+ if (!fs.existsSync(p)) return { ...DEFAULT_CONFIG };
86
+ try {
87
+ return { ...DEFAULT_CONFIG, ...JSON.parse(fs.readFileSync(p, 'utf-8')) };
88
+ } catch {
89
+ return { ...DEFAULT_CONFIG };
90
+ }
91
+ }
92
+
93
+ /** Write config to .tycono/config.json. Creates dir if needed. */
94
+ export function writeConfig(companyRoot: string, config: CompanyConfig): void {
95
+ const dir = path.join(companyRoot, CONFIG_DIR);
96
+ fs.mkdirSync(dir, { recursive: true });
97
+ fs.writeFileSync(configPath(companyRoot), JSON.stringify(config, null, 2) + '\n');
98
+ }
99
+
100
+ /**
101
+ * Load config and apply to process.env.
102
+ * Called once at server startup.
103
+ */
104
+ export function applyConfig(companyRoot: string): CompanyConfig {
105
+ const config = readConfig(companyRoot);
106
+
107
+ if (config.apiKey && !process.env.ANTHROPIC_API_KEY) {
108
+ process.env.ANTHROPIC_API_KEY = config.apiKey;
109
+ }
110
+ if (!process.env.EXECUTION_ENGINE) {
111
+ process.env.EXECUTION_ENGINE = config.engine;
112
+ }
113
+
114
+ return config;
115
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Database Service — SQLite for operational data
3
+ *
4
+ * Stores: sessions, messages, waves, wave_messages, activity_events, cost
5
+ * NOT stored: knowledge/, CLAUDE.md, role.yaml, skills/ (stay as files for AI grep + git diff)
6
+ *
7
+ * Location: .tycono/tycono.db (single file, no server)
8
+ */
9
+
10
+ import Database from 'better-sqlite3';
11
+ import path from 'node:path';
12
+ import fs from 'node:fs';
13
+ import { COMPANY_ROOT } from './file-reader.js';
14
+
15
+ let db: Database.Database | null = null;
16
+
17
+ export function getDb(): Database.Database {
18
+ if (db) return db;
19
+
20
+ const dbDir = path.join(COMPANY_ROOT, '.tycono');
21
+ if (!fs.existsSync(dbDir)) fs.mkdirSync(dbDir, { recursive: true });
22
+
23
+ const dbPath = path.join(dbDir, 'tycono.db');
24
+ db = new Database(dbPath);
25
+
26
+ // Performance: WAL mode for concurrent reads during writes
27
+ db.pragma('journal_mode = WAL');
28
+ db.pragma('synchronous = NORMAL');
29
+ db.pragma('foreign_keys = ON');
30
+
31
+ initSchema(db);
32
+ return db;
33
+ }
34
+
35
+ export function closeDb(): void {
36
+ if (db) {
37
+ db.close();
38
+ db = null;
39
+ }
40
+ }
41
+
42
+ function initSchema(db: Database.Database): void {
43
+ db.exec(`
44
+ -- ── Wave Messages (CEO↔Supervisor conversation history) ──
45
+ CREATE TABLE IF NOT EXISTS wave_message (
46
+ seq INTEGER NOT NULL,
47
+ wave_id TEXT NOT NULL,
48
+ role TEXT NOT NULL CHECK (role IN ('user', 'assistant', 'summary')),
49
+ content TEXT NOT NULL,
50
+ ts TEXT NOT NULL,
51
+ execution_id TEXT,
52
+ metadata TEXT,
53
+ summarizes_start_seq INTEGER,
54
+ summarizes_end_seq INTEGER,
55
+ PRIMARY KEY (wave_id, seq)
56
+ );
57
+
58
+ CREATE INDEX IF NOT EXISTS idx_wave_message_wave ON wave_message(wave_id);
59
+
60
+ -- ── Activity Events (execution event log) ──
61
+ CREATE TABLE IF NOT EXISTS activity_event (
62
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
63
+ session_id TEXT NOT NULL,
64
+ seq INTEGER NOT NULL,
65
+ ts TEXT NOT NULL,
66
+ type TEXT NOT NULL,
67
+ role_id TEXT NOT NULL,
68
+ trace_id TEXT,
69
+ parent_session_id TEXT,
70
+ data TEXT NOT NULL DEFAULT '{}',
71
+ UNIQUE(session_id, seq)
72
+ );
73
+
74
+ CREATE INDEX IF NOT EXISTS idx_activity_session ON activity_event(session_id);
75
+ CREATE INDEX IF NOT EXISTS idx_activity_session_seq ON activity_event(session_id, seq);
76
+ `);
77
+ }
@@ -0,0 +1,313 @@
1
+ /**
2
+ * DigestEngine — Server-side JSONL event summarizer for C-Level supervision.
3
+ *
4
+ * Pure TypeScript, zero LLM calls ($0 cost).
5
+ * Classifies activity events by significance tier, detects anomalies,
6
+ * and produces a concise digest for C-Level consumption.
7
+ *
8
+ * SV-2: Core supervision service
9
+ */
10
+ import type { ActivityEvent, ActivityEventType } from '../../../shared/types.js';
11
+
12
+ /* ─── Types ──────────────────────────────────── */
13
+
14
+ export interface Anomaly {
15
+ type: 'error' | 'stall' | 'scope_creep' | 'awaiting_input' | 'budget_warning' | 'ceo_directive';
16
+ sessionId: string;
17
+ message: string;
18
+ severity: number; // 0-10
19
+ }
20
+
21
+ export interface DigestResult {
22
+ text: string; // C-Level readable summary (<500 tokens quiet, <2000 active)
23
+ significanceScore: number; // 0-10
24
+ anomalies: Anomaly[];
25
+ checkpoints: Map<string, number>; // sessionId → lastSeq
26
+ peerActivity?: string; // peer C-Level activity summary
27
+ eventCount: number;
28
+ errorCount: number;
29
+ }
30
+
31
+ /* ─── Event Classification ───────────────────── */
32
+
33
+ type EventTier = 'critical' | 'high' | 'medium' | 'low';
34
+
35
+ const EVENT_TIER_MAP: Partial<Record<ActivityEventType, EventTier>> = {
36
+ 'msg:error': 'critical',
37
+ 'msg:awaiting_input': 'critical',
38
+ 'dispatch:start': 'high',
39
+ 'dispatch:done': 'high',
40
+ 'msg:done': 'high',
41
+ 'msg:start': 'high',
42
+ 'thinking': 'medium',
43
+ 'text': 'low',
44
+ 'stderr': 'medium',
45
+ 'msg:turn-complete': 'low',
46
+ 'turn:warning': 'high',
47
+ 'turn:limit': 'critical',
48
+ };
49
+
50
+ const TIER_WEIGHT: Record<EventTier, number> = {
51
+ critical: 10,
52
+ high: 5,
53
+ medium: 2,
54
+ low: 0,
55
+ };
56
+
57
+ function classifyEvent(event: ActivityEvent): EventTier {
58
+ // tool:start classification depends on tool name
59
+ if (event.type === 'tool:start') {
60
+ const toolName = (event.data?.name as string) ?? '';
61
+ const highTools = ['write_file', 'edit_file', 'bash_execute', 'dispatch', 'consult'];
62
+ if (highTools.includes(toolName)) return 'high';
63
+ return 'medium';
64
+ }
65
+
66
+ if (event.type === 'tool:result') {
67
+ const isError = event.data?.is_error === true;
68
+ return isError ? 'high' : 'low';
69
+ }
70
+
71
+ return EVENT_TIER_MAP[event.type] ?? 'low';
72
+ }
73
+
74
+ /* ─── Anomaly Detection ──────────────────────── */
75
+
76
+ interface SessionState {
77
+ sessionId: string;
78
+ roleId: string;
79
+ lastEventTs: number;
80
+ eventCount: number;
81
+ errorCount: number;
82
+ isDone: boolean;
83
+ isError: boolean;
84
+ isAwaitingInput: boolean;
85
+ toolCalls: string[]; // tool names used
86
+ filesModified: string[]; // file paths modified
87
+ }
88
+
89
+ function detectAnomalies(
90
+ sessionStates: Map<string, SessionState>,
91
+ now: number,
92
+ ): Anomaly[] {
93
+ const anomalies: Anomaly[] = [];
94
+
95
+ for (const [sessionId, state] of sessionStates) {
96
+ // Stall detection: 3+ minutes without events
97
+ if (!state.isDone && !state.isError && !state.isAwaitingInput) {
98
+ const silenceMs = now - state.lastEventTs;
99
+ if (silenceMs > 3 * 60 * 1000) {
100
+ anomalies.push({
101
+ type: 'stall',
102
+ sessionId,
103
+ message: `Session ${sessionId} (${state.roleId}): No events for ${Math.round(silenceMs / 60000)}min`,
104
+ severity: 7,
105
+ });
106
+ }
107
+ }
108
+
109
+ // Error detection
110
+ if (state.isError) {
111
+ anomalies.push({
112
+ type: 'error',
113
+ sessionId,
114
+ message: `Session ${sessionId} (${state.roleId}): Ended with error`,
115
+ severity: 10,
116
+ });
117
+ }
118
+
119
+ // Awaiting input
120
+ if (state.isAwaitingInput) {
121
+ anomalies.push({
122
+ type: 'awaiting_input',
123
+ sessionId,
124
+ message: `Session ${sessionId} (${state.roleId}): Awaiting input`,
125
+ severity: 8,
126
+ });
127
+ }
128
+ }
129
+
130
+ return anomalies;
131
+ }
132
+
133
+ /* ─── Digest Builder ─────────────────────────── */
134
+
135
+ function buildDigestText(
136
+ sessionStates: Map<string, SessionState>,
137
+ eventsBySession: Map<string, ActivityEvent[]>,
138
+ anomalies: Anomaly[],
139
+ significanceScore: number,
140
+ ): string {
141
+ const parts: string[] = [];
142
+
143
+ // Header
144
+ const totalEvents = Array.from(eventsBySession.values()).reduce((sum, evts) => sum + evts.length, 0);
145
+ const totalErrors = Array.from(sessionStates.values()).reduce((sum, s) => sum + s.errorCount, 0);
146
+ const activeSessions = Array.from(sessionStates.values()).filter(s => !s.isDone && !s.isError).length;
147
+ const doneSessions = Array.from(sessionStates.values()).filter(s => s.isDone).length;
148
+
149
+ parts.push(`## Supervision Digest [score: ${significanceScore}/10]`);
150
+ parts.push(`Sessions: ${activeSessions} active, ${doneSessions} done | Events: ${totalEvents} | Errors: ${totalErrors}`);
151
+ parts.push('');
152
+
153
+ // Anomalies first (most important)
154
+ if (anomalies.length > 0) {
155
+ parts.push('### ⚠️ Anomalies');
156
+ for (const a of anomalies) {
157
+ const icon = a.type === 'error' ? '🔴' : a.type === 'stall' ? '🟡' : a.type === 'awaiting_input' ? '🟠' : '⚪';
158
+ parts.push(`- ${icon} **${a.type}**: ${a.message}`);
159
+ }
160
+ parts.push('');
161
+ }
162
+
163
+ // Per-session summary
164
+ parts.push('### Session Activity');
165
+ for (const [sessionId, state] of sessionStates) {
166
+ const events = eventsBySession.get(sessionId) ?? [];
167
+ const status = state.isDone ? '✅ Done' : state.isError ? '❌ Error' : state.isAwaitingInput ? '🟠 Awaiting' : '🔵 Active';
168
+
169
+ parts.push(`**[${state.roleId}]** ${sessionId} — ${status} (${events.length} events)`);
170
+
171
+ // Highlight significant events
172
+ const significant = events.filter(e => {
173
+ const tier = classifyEvent(e);
174
+ return tier === 'critical' || tier === 'high';
175
+ });
176
+
177
+ for (const evt of significant.slice(-5)) { // Last 5 significant events
178
+ const summary = summarizeEvent(evt);
179
+ if (summary) parts.push(` - ${summary}`);
180
+ }
181
+ }
182
+
183
+ return parts.join('\n');
184
+ }
185
+
186
+ function summarizeEvent(event: ActivityEvent): string | null {
187
+ switch (event.type) {
188
+ case 'msg:start':
189
+ return `Started: ${(event.data?.task as string ?? '').slice(0, 80)}`;
190
+ case 'msg:done':
191
+ return `Completed (${event.data?.turns ?? '?'} turns)`;
192
+ case 'msg:error':
193
+ return `Error: ${(event.data?.message as string ?? 'unknown').slice(0, 100)}`;
194
+ case 'msg:awaiting_input':
195
+ return `Awaiting input: ${(event.data?.question as string ?? '').slice(0, 80)}`;
196
+ case 'dispatch:start':
197
+ return `Dispatched → ${event.data?.targetRoleId}: ${(event.data?.task as string ?? '').slice(0, 60)}`;
198
+ case 'dispatch:done':
199
+ return `Dispatch completed: ${event.data?.targetRoleId}`;
200
+ case 'tool:start': {
201
+ const toolName = event.data?.name as string ?? 'unknown';
202
+ const input = event.data?.input as Record<string, unknown> | undefined;
203
+ if (toolName === 'write_file' || toolName === 'edit_file') {
204
+ return `${toolName}: ${(input?.path as string ?? '').slice(0, 60)}`;
205
+ }
206
+ if (toolName === 'bash_execute') {
207
+ return `bash: ${(input?.command as string ?? '').slice(0, 60)}`;
208
+ }
209
+ return null; // Skip read-only tools in summary
210
+ }
211
+ case 'turn:warning':
212
+ return `⚠️ Turn limit warning (${event.data?.turn}/${event.data?.hardLimit})`;
213
+ case 'turn:limit':
214
+ return `🔴 Turn limit reached (${event.data?.turn})`;
215
+ default:
216
+ return null;
217
+ }
218
+ }
219
+
220
+ /* ─── Public API ─────────────────────────────── */
221
+
222
+ /**
223
+ * Digest a set of events from multiple sessions.
224
+ *
225
+ * @param eventsBySession - Map of sessionId → events collected during the watch period
226
+ * @param peerEvents - Optional events from peer C-Level sessions
227
+ */
228
+ export function digest(
229
+ eventsBySession: Map<string, ActivityEvent[]>,
230
+ peerEvents?: Map<string, ActivityEvent[]>,
231
+ ): DigestResult {
232
+ const now = Date.now();
233
+ const sessionStates = new Map<string, SessionState>();
234
+ const checkpoints = new Map<string, number>();
235
+
236
+ // Build session states
237
+ for (const [sessionId, events] of eventsBySession) {
238
+ if (events.length === 0) continue;
239
+
240
+ const state: SessionState = {
241
+ sessionId,
242
+ roleId: events[0].roleId,
243
+ lastEventTs: new Date(events[events.length - 1].ts).getTime(),
244
+ eventCount: events.length,
245
+ errorCount: events.filter(e => e.type === 'msg:error' || (e.type === 'tool:result' && e.data?.is_error)).length,
246
+ isDone: events.some(e => e.type === 'msg:done'),
247
+ isError: events.some(e => e.type === 'msg:error'),
248
+ isAwaitingInput: events.some(e => e.type === 'msg:awaiting_input') && !events.some(e => e.type === 'msg:done'),
249
+ toolCalls: events.filter(e => e.type === 'tool:start').map(e => e.data?.name as string).filter(Boolean),
250
+ filesModified: events
251
+ .filter(e => e.type === 'tool:start' && ['write_file', 'edit_file'].includes(e.data?.name as string))
252
+ .map(e => (e.data?.input as Record<string, unknown>)?.path as string)
253
+ .filter(Boolean),
254
+ };
255
+ sessionStates.set(sessionId, state);
256
+ checkpoints.set(sessionId, events[events.length - 1].seq);
257
+ }
258
+
259
+ // Calculate significance score
260
+ let maxWeight = 0;
261
+ for (const events of eventsBySession.values()) {
262
+ for (const event of events) {
263
+ const tier = classifyEvent(event);
264
+ const weight = TIER_WEIGHT[tier];
265
+ if (weight > maxWeight) maxWeight = weight;
266
+ }
267
+ }
268
+
269
+ const anomalies = detectAnomalies(sessionStates, now);
270
+ const anomalyBoost = anomalies.length > 0 ? Math.min(anomalies.reduce((sum, a) => sum + a.severity, 0), 10) : 0;
271
+ const significanceScore = Math.min(10, Math.max(maxWeight, anomalyBoost));
272
+
273
+ // Build digest text
274
+ const text = buildDigestText(sessionStates, eventsBySession, anomalies, significanceScore);
275
+
276
+ // Peer activity digest
277
+ let peerActivity: string | undefined;
278
+ if (peerEvents && peerEvents.size > 0) {
279
+ const peerLines: string[] = ['## Peer Activity'];
280
+ for (const [sessionId, events] of peerEvents) {
281
+ if (events.length === 0) continue;
282
+ const roleId = events[0].roleId;
283
+ const significant = events.filter(e => {
284
+ const tier = classifyEvent(e);
285
+ return tier === 'critical' || tier === 'high';
286
+ });
287
+ for (const evt of significant.slice(-3)) {
288
+ const summary = summarizeEvent(evt);
289
+ if (summary) peerLines.push(`[${roleId}] ${summary}`);
290
+ }
291
+ }
292
+ if (peerLines.length > 1) {
293
+ peerActivity = peerLines.join('\n');
294
+ }
295
+ }
296
+
297
+ return {
298
+ text: peerActivity ? `${text}\n\n${peerActivity}` : text,
299
+ significanceScore,
300
+ anomalies,
301
+ checkpoints,
302
+ peerActivity,
303
+ eventCount: Array.from(eventsBySession.values()).reduce((sum, evts) => sum + evts.length, 0),
304
+ errorCount: Array.from(sessionStates.values()).reduce((sum, s) => sum + s.errorCount, 0),
305
+ };
306
+ }
307
+
308
+ /**
309
+ * Generate a quiet tick summary (for significanceScore < 2 && no anomalies)
310
+ */
311
+ export function quietDigest(sessionCount: number, eventCount: number, errorCount: number): string {
312
+ return `All ${sessionCount} sessions progressing normally. No anomalies. [${eventCount} events, ${errorCount} errors]`;
313
+ }