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.
- package/bin/cli.js +35 -0
- package/bin/server.ts +160 -0
- package/package.json +50 -0
- package/src/api/package.json +31 -0
- package/src/api/src/create-app.ts +90 -0
- package/src/api/src/create-server.ts +251 -0
- package/src/api/src/engine/agent-loop.ts +738 -0
- package/src/api/src/engine/authority-validator.ts +149 -0
- package/src/api/src/engine/context-assembler.ts +912 -0
- package/src/api/src/engine/index.ts +27 -0
- package/src/api/src/engine/knowledge-gate.ts +365 -0
- package/src/api/src/engine/llm-adapter.ts +304 -0
- package/src/api/src/engine/org-tree.ts +270 -0
- package/src/api/src/engine/role-lifecycle.ts +369 -0
- package/src/api/src/engine/runners/claude-cli.ts +796 -0
- package/src/api/src/engine/runners/direct-api.ts +66 -0
- package/src/api/src/engine/runners/index.ts +30 -0
- package/src/api/src/engine/runners/types.ts +95 -0
- package/src/api/src/engine/skill-template.ts +134 -0
- package/src/api/src/engine/tools/definitions.ts +201 -0
- package/src/api/src/engine/tools/executor.ts +611 -0
- package/src/api/src/routes/active-sessions.ts +134 -0
- package/src/api/src/routes/coins.ts +153 -0
- package/src/api/src/routes/company.ts +57 -0
- package/src/api/src/routes/cost.ts +141 -0
- package/src/api/src/routes/engine.ts +220 -0
- package/src/api/src/routes/execute.ts +1075 -0
- package/src/api/src/routes/git.ts +211 -0
- package/src/api/src/routes/knowledge.ts +378 -0
- package/src/api/src/routes/operations.ts +309 -0
- package/src/api/src/routes/preferences.ts +63 -0
- package/src/api/src/routes/presets.ts +123 -0
- package/src/api/src/routes/projects.ts +82 -0
- package/src/api/src/routes/quests.ts +41 -0
- package/src/api/src/routes/roles.ts +112 -0
- package/src/api/src/routes/save.ts +152 -0
- package/src/api/src/routes/sessions.ts +288 -0
- package/src/api/src/routes/setup.ts +437 -0
- package/src/api/src/routes/skills.ts +357 -0
- package/src/api/src/routes/speech.ts +959 -0
- package/src/api/src/routes/supervision.ts +136 -0
- package/src/api/src/routes/sync.ts +165 -0
- package/src/api/src/server.ts +59 -0
- package/src/api/src/services/activity-stream.ts +184 -0
- package/src/api/src/services/activity-tracker.ts +115 -0
- package/src/api/src/services/claude-md-manager.ts +94 -0
- package/src/api/src/services/company-config.ts +115 -0
- package/src/api/src/services/database.ts +77 -0
- package/src/api/src/services/digest-engine.ts +313 -0
- package/src/api/src/services/execution-manager.ts +1036 -0
- package/src/api/src/services/file-reader.ts +77 -0
- package/src/api/src/services/git-save.ts +614 -0
- package/src/api/src/services/job-manager.ts +16 -0
- package/src/api/src/services/knowledge-importer.ts +466 -0
- package/src/api/src/services/markdown-parser.ts +173 -0
- package/src/api/src/services/port-registry.ts +222 -0
- package/src/api/src/services/preferences.ts +150 -0
- package/src/api/src/services/preset-loader.ts +149 -0
- package/src/api/src/services/pricing.ts +34 -0
- package/src/api/src/services/scaffold.ts +546 -0
- package/src/api/src/services/session-store.ts +340 -0
- package/src/api/src/services/supervisor-heartbeat.ts +897 -0
- package/src/api/src/services/team-recommender.ts +382 -0
- package/src/api/src/services/token-ledger.ts +127 -0
- package/src/api/src/services/wave-messages.ts +194 -0
- package/src/api/src/services/wave-multiplexer.ts +356 -0
- package/src/api/src/services/wave-tracker.ts +359 -0
- package/src/api/src/utils/role-level.ts +31 -0
- package/src/core/scaffolder.ts +620 -0
- package/src/shared/types.ts +224 -0
- package/templates/CLAUDE.md.tmpl +239 -0
- package/templates/company.md.tmpl +17 -0
- package/templates/gitignore.tmpl +28 -0
- package/templates/roles.md.tmpl +8 -0
- package/templates/skills/_manifest.json +23 -0
- package/templates/skills/agent-browser/SKILL.md +159 -0
- package/templates/skills/agent-browser/meta.json +19 -0
- package/templates/skills/akb-linter/SKILL.md +125 -0
- package/templates/skills/akb-linter/meta.json +12 -0
- package/templates/skills/knowledge-gate/SKILL.md +120 -0
- package/templates/skills/knowledge-gate/meta.json +12 -0
- package/templates/teams/agency.json +58 -0
- package/templates/teams/research.json +58 -0
- 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
|
+
}
|