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,222 @@
1
+ /**
2
+ * port-registry.ts — Session port allocation and tracking
3
+ *
4
+ * Manages port assignments for parallel dev server sessions.
5
+ * Each job/session gets unique API + Vite ports to avoid conflicts.
6
+ */
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import net from 'node:net';
10
+ import { COMPANY_ROOT } from './file-reader.js';
11
+
12
+ /* ─── Types ──────────────────────────────── */
13
+
14
+ export interface PortAllocation {
15
+ api: number;
16
+ vite: number;
17
+ hmr?: number;
18
+ }
19
+
20
+ export interface SessionPort {
21
+ sessionId: string;
22
+ roleId: string;
23
+ task: string;
24
+ ports: PortAllocation;
25
+ worktreePath?: string;
26
+ pid?: number;
27
+ startedAt: string;
28
+ status: 'active' | 'idle' | 'dead';
29
+ }
30
+
31
+ interface RegistryFile {
32
+ sessions: SessionPort[];
33
+ }
34
+
35
+ /* ─── Port Pools ─────────────────────────── */
36
+
37
+ const API_PORT_START = 3001;
38
+ const VITE_PORT_START = 5173;
39
+ const HMR_PORT_START = 24678;
40
+ const POOL_SIZE = 10;
41
+
42
+ /* ─── Helpers ────────────────────────────── */
43
+
44
+ function getRegistryPath(): string {
45
+ return path.join(COMPANY_ROOT, '.tycono', 'port-registry.json');
46
+ }
47
+
48
+ function readRegistry(): RegistryFile {
49
+ const filePath = getRegistryPath();
50
+ try {
51
+ if (fs.existsSync(filePath)) {
52
+ return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
53
+ }
54
+ } catch { /* ignore corrupt file */ }
55
+ return { sessions: [] };
56
+ }
57
+
58
+ function writeRegistry(data: RegistryFile): void {
59
+ const filePath = getRegistryPath();
60
+ const dir = path.dirname(filePath);
61
+ if (!fs.existsSync(dir)) {
62
+ fs.mkdirSync(dir, { recursive: true });
63
+ }
64
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
65
+ }
66
+
67
+ function isProcessAlive(pid: number): boolean {
68
+ try {
69
+ process.kill(pid, 0);
70
+ return true;
71
+ } catch {
72
+ return false;
73
+ }
74
+ }
75
+
76
+ function isPortAvailable(port: number): Promise<boolean> {
77
+ return new Promise((resolve) => {
78
+ const server = net.createServer();
79
+ server.once('error', () => resolve(false));
80
+ server.once('listening', () => {
81
+ server.close();
82
+ resolve(true);
83
+ });
84
+ server.listen(port, '127.0.0.1');
85
+ });
86
+ }
87
+
88
+ /* ─── PortRegistry ───────────────────────── */
89
+
90
+ class PortRegistry {
91
+ /** Allocate ports for a new session */
92
+ async allocate(sessionId: string, roleId: string, task: string): Promise<PortAllocation> {
93
+ const registry = readRegistry();
94
+ const usedApi = new Set(registry.sessions.map(s => s.ports.api));
95
+ const usedVite = new Set(registry.sessions.map(s => s.ports.vite));
96
+ const usedHmr = new Set(registry.sessions.filter(s => s.ports.hmr).map(s => s.ports.hmr!));
97
+
98
+ // Find first available port in pool
99
+ let api = 0;
100
+ let vite = 0;
101
+ let hmr = 0;
102
+
103
+ for (let i = 0; i < POOL_SIZE; i++) {
104
+ const candidate = API_PORT_START + i;
105
+ if (!usedApi.has(candidate) && await isPortAvailable(candidate)) {
106
+ api = candidate;
107
+ break;
108
+ }
109
+ }
110
+
111
+ for (let i = 0; i < POOL_SIZE; i++) {
112
+ const candidate = VITE_PORT_START + i;
113
+ if (!usedVite.has(candidate) && await isPortAvailable(candidate)) {
114
+ vite = candidate;
115
+ break;
116
+ }
117
+ }
118
+
119
+ for (let i = 0; i < POOL_SIZE; i++) {
120
+ const candidate = HMR_PORT_START + i;
121
+ if (!usedHmr.has(candidate) && await isPortAvailable(candidate)) {
122
+ hmr = candidate;
123
+ break;
124
+ }
125
+ }
126
+
127
+ // Fallback: let OS pick
128
+ if (!api) api = 0;
129
+ if (!vite) vite = 0;
130
+
131
+ const ports: PortAllocation = { api, vite };
132
+ if (hmr) ports.hmr = hmr;
133
+
134
+ const session: SessionPort = {
135
+ sessionId,
136
+ roleId,
137
+ task: task.slice(0, 80),
138
+ ports,
139
+ startedAt: new Date().toISOString(),
140
+ status: 'active',
141
+ };
142
+
143
+ registry.sessions.push(session);
144
+ writeRegistry(registry);
145
+
146
+ return ports;
147
+ }
148
+
149
+ /** Release ports when a session ends */
150
+ release(sessionId: string): boolean {
151
+ const registry = readRegistry();
152
+ const before = registry.sessions.length;
153
+ registry.sessions = registry.sessions.filter(s => s.sessionId !== sessionId);
154
+ if (registry.sessions.length < before) {
155
+ writeRegistry(registry);
156
+ return true;
157
+ }
158
+ return false;
159
+ }
160
+
161
+ /** Update session info (e.g., set PID, worktree path) */
162
+ update(sessionId: string, patch: Partial<Pick<SessionPort, 'pid' | 'worktreePath' | 'status' | 'task'>>): boolean {
163
+ const registry = readRegistry();
164
+ const session = registry.sessions.find(s => s.sessionId === sessionId);
165
+ if (!session) return false;
166
+
167
+ if (patch.pid !== undefined) session.pid = patch.pid;
168
+ if (patch.worktreePath !== undefined) session.worktreePath = patch.worktreePath;
169
+ if (patch.status !== undefined) session.status = patch.status;
170
+ if (patch.task !== undefined) session.task = patch.task.slice(0, 80);
171
+
172
+ writeRegistry(registry);
173
+ return true;
174
+ }
175
+
176
+ /** Get all sessions */
177
+ getAll(): SessionPort[] {
178
+ return readRegistry().sessions;
179
+ }
180
+
181
+ /** Get a specific session */
182
+ get(sessionId: string): SessionPort | null {
183
+ return readRegistry().sessions.find(s => s.sessionId === sessionId) ?? null;
184
+ }
185
+
186
+ /** Detect and clean up dead sessions (PID gone) */
187
+ cleanup(): { cleaned: SessionPort[]; remaining: SessionPort[] } {
188
+ const registry = readRegistry();
189
+ const cleaned: SessionPort[] = [];
190
+ const remaining: SessionPort[] = [];
191
+
192
+ for (const session of registry.sessions) {
193
+ if (session.pid && !isProcessAlive(session.pid)) {
194
+ session.status = 'dead';
195
+ cleaned.push(session);
196
+ } else {
197
+ remaining.push(session);
198
+ }
199
+ }
200
+
201
+ if (cleaned.length > 0) {
202
+ registry.sessions = remaining;
203
+ writeRegistry(registry);
204
+ }
205
+
206
+ return { cleaned, remaining };
207
+ }
208
+
209
+ /** Get summary stats */
210
+ getSummary(): { active: number; totalPorts: number } {
211
+ const sessions = this.getAll();
212
+ const active = sessions.filter(s => s.status === 'active').length;
213
+ return {
214
+ active,
215
+ totalPorts: active * 2, // api + vite per session
216
+ };
217
+ }
218
+ }
219
+
220
+ /* ─── Export singleton ───────────────────── */
221
+
222
+ export const portRegistry = new PortRegistry();
@@ -0,0 +1,150 @@
1
+ /**
2
+ * preferences.ts — .tycono/preferences.json 관리
3
+ *
4
+ * 캐릭터 외모, 오피스 테마 등 사용자 설정을 서버 파일로 영속화한다.
5
+ * company-config.ts의 readConfig/writeConfig 패턴을 따른다.
6
+ */
7
+ import crypto from 'node:crypto';
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+
11
+ export interface CharacterAppearance {
12
+ skinColor: string;
13
+ hairColor: string;
14
+ shirtColor: string;
15
+ pantsColor: string;
16
+ shoeColor: string;
17
+ }
18
+
19
+ export interface SpeechSettings {
20
+ /** 'template' = static pool only, 'llm' = AI generation, 'auto' = detect engine */
21
+ mode: 'template' | 'llm' | 'auto';
22
+ /** Interval between ambient speech in seconds */
23
+ intervalSec: number;
24
+ /** Daily budget for LLM speech in USD (0 = unlimited) */
25
+ dailyBudgetUsd: number;
26
+ }
27
+
28
+ export interface FurnitureOverride {
29
+ offsetX: number;
30
+ offsetY: number;
31
+ }
32
+
33
+ export interface DeskOverride {
34
+ dx: number;
35
+ dy: number;
36
+ }
37
+
38
+ export interface AddedFurniture {
39
+ id: string;
40
+ type: string;
41
+ room: string;
42
+ zone: 'wall' | 'floor';
43
+ anchorX?: 'left' | 'right';
44
+ offsetX: number;
45
+ offsetY: number;
46
+ accent?: string;
47
+ }
48
+
49
+ export interface OfficeExpansion {
50
+ /** Each entry = one floor. floors[0] = 1F, floors[1] = 2F, etc. Max 3 floors. */
51
+ floors: Array<{ rooms: 4 | 6 }>;
52
+ purchaseHistory: Array<{ type: string; cost: number; ts: string }>;
53
+ }
54
+
55
+ export interface Preferences {
56
+ instanceId?: string; // anonymous persistent token — auto-generated on first read
57
+ appearances: Record<string, CharacterAppearance>;
58
+ theme: string;
59
+ speech?: SpeechSettings;
60
+ language?: string; // 'en' | 'ko' | 'ja' | 'auto'
61
+ furnitureOverrides?: Record<string, FurnitureOverride>; // keyed by FurnitureDef.id
62
+ deskOverrides?: Record<string, DeskOverride>; // keyed by role id
63
+ removedFurniture?: string[]; // FurnitureDef.id list
64
+ addedFurniture?: AddedFurniture[];
65
+ officeExpansion?: OfficeExpansion;
66
+ purchasedItems?: string[]; // item IDs purchased with coins (hair, outfit, accessory)
67
+ }
68
+
69
+ const CONFIG_DIR = '.tycono';
70
+ const PREFS_FILE = 'preferences.json';
71
+ const DEFAULT: Preferences = { appearances: {}, theme: 'default' };
72
+
73
+ function prefsPath(companyRoot: string): string {
74
+ return path.join(companyRoot, CONFIG_DIR, PREFS_FILE);
75
+ }
76
+
77
+ /** Read preferences from .tycono/preferences.json. Returns defaults if missing.
78
+ * Auto-generates instanceId on first access and persists it. */
79
+ export function readPreferences(companyRoot: string): Preferences {
80
+ const p = prefsPath(companyRoot);
81
+ let data: Record<string, unknown> = {};
82
+ if (fs.existsSync(p)) {
83
+ try { data = JSON.parse(fs.readFileSync(p, 'utf-8')); } catch { /* use defaults */ }
84
+ }
85
+
86
+ const prefs: Preferences = {
87
+ instanceId: (data.instanceId as string) ?? undefined,
88
+ appearances: (data.appearances as Record<string, CharacterAppearance>) ?? {},
89
+ theme: (data.theme as string) ?? 'default',
90
+ speech: (data.speech as SpeechSettings) ?? undefined,
91
+ language: (data.language as string) ?? undefined,
92
+ furnitureOverrides: (data.furnitureOverrides as Record<string, FurnitureOverride>) ?? undefined,
93
+ deskOverrides: (data.deskOverrides as Record<string, DeskOverride>) ?? undefined,
94
+ removedFurniture: (data.removedFurniture as string[]) ?? undefined,
95
+ addedFurniture: (data.addedFurniture as AddedFurniture[]) ?? undefined,
96
+ officeExpansion: (data.officeExpansion as OfficeExpansion) ?? undefined,
97
+ purchasedItems: (data.purchasedItems as string[]) ?? undefined,
98
+ };
99
+
100
+ // Auto-generate instanceId on first access
101
+ if (!prefs.instanceId) {
102
+ prefs.instanceId = crypto.randomUUID();
103
+ writePreferences(companyRoot, prefs);
104
+ }
105
+
106
+ return prefs;
107
+ }
108
+
109
+ /** Write preferences to .tycono/preferences.json. Creates dir if needed. */
110
+ export function writePreferences(companyRoot: string, prefs: Preferences): void {
111
+ const dir = path.join(companyRoot, CONFIG_DIR);
112
+ fs.mkdirSync(dir, { recursive: true });
113
+ fs.writeFileSync(prefsPath(companyRoot), JSON.stringify(prefs, null, 2) + '\n');
114
+ }
115
+
116
+ /** Merge partial preferences into existing. instanceId is never overwritten by client. */
117
+ export function mergePreferences(companyRoot: string, partial: Partial<Preferences>): Preferences {
118
+ const current = readPreferences(companyRoot);
119
+ const merged: Preferences = {
120
+ instanceId: current.instanceId, // preserve — never overwrite from client
121
+ appearances: partial.appearances !== undefined
122
+ ? { ...current.appearances, ...partial.appearances }
123
+ : current.appearances,
124
+ theme: partial.theme ?? current.theme,
125
+ speech: partial.speech !== undefined
126
+ ? { ...current.speech, ...partial.speech }
127
+ : current.speech,
128
+ language: partial.language !== undefined ? partial.language : current.language,
129
+ furnitureOverrides: partial.furnitureOverrides !== undefined
130
+ ? { ...current.furnitureOverrides, ...partial.furnitureOverrides }
131
+ : current.furnitureOverrides,
132
+ deskOverrides: partial.deskOverrides !== undefined
133
+ ? { ...current.deskOverrides, ...partial.deskOverrides }
134
+ : current.deskOverrides,
135
+ removedFurniture: partial.removedFurniture !== undefined
136
+ ? partial.removedFurniture
137
+ : current.removedFurniture,
138
+ addedFurniture: partial.addedFurniture !== undefined
139
+ ? partial.addedFurniture
140
+ : current.addedFurniture,
141
+ officeExpansion: partial.officeExpansion !== undefined
142
+ ? partial.officeExpansion
143
+ : current.officeExpansion,
144
+ purchasedItems: partial.purchasedItems !== undefined
145
+ ? partial.purchasedItems
146
+ : current.purchasedItems,
147
+ };
148
+ writePreferences(companyRoot, merged);
149
+ return merged;
150
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * preset-loader.ts — Load presets from knowledge/presets/
3
+ *
4
+ * Scans knowledge/presets/ for:
5
+ * - _default.yaml (auto-generated from existing knowledge/roles/)
6
+ * - {name}/preset.yaml (installed presets with roles/skills/knowledge)
7
+ *
8
+ * Returns PresetSummary[] for TUI display and full LoadedPreset for wave creation.
9
+ */
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import YAML from 'yaml';
13
+ import type { PresetDefinition, LoadedPreset, PresetSummary } from '../../../shared/types.js';
14
+
15
+ const PRESETS_DIR = 'knowledge/presets';
16
+ const DEFAULT_PRESET_FILE = '_default.yaml';
17
+
18
+ /**
19
+ * Build a default preset definition from existing roles/ directory.
20
+ * This is generated on-the-fly — no need to persist _default.yaml.
21
+ */
22
+ function buildDefaultPreset(companyRoot: string): LoadedPreset {
23
+ const rolesDir = path.join(companyRoot, 'knowledge', 'roles');
24
+ const roles: string[] = [];
25
+
26
+ if (fs.existsSync(rolesDir)) {
27
+ const entries = fs.readdirSync(rolesDir, { withFileTypes: true });
28
+ for (const entry of entries) {
29
+ if (!entry.isDirectory()) continue;
30
+ const yamlPath = path.join(rolesDir, entry.name, 'role.yaml');
31
+ if (fs.existsSync(yamlPath)) {
32
+ roles.push(entry.name);
33
+ }
34
+ }
35
+ }
36
+
37
+ return {
38
+ definition: {
39
+ spec: 'preset/v1',
40
+ id: 'default',
41
+ name: 'Default Team',
42
+ tagline: 'Your current team',
43
+ version: '1.0.0',
44
+ roles,
45
+ },
46
+ path: null,
47
+ isDefault: true,
48
+ };
49
+ }
50
+
51
+ /**
52
+ * Load a single preset from a directory containing preset.yaml.
53
+ */
54
+ function loadPresetFromDir(presetDir: string): LoadedPreset | null {
55
+ const yamlPath = path.join(presetDir, 'preset.yaml');
56
+ if (!fs.existsSync(yamlPath)) return null;
57
+
58
+ try {
59
+ const raw = YAML.parse(fs.readFileSync(yamlPath, 'utf-8')) as PresetDefinition;
60
+ if (!raw.id || !raw.name || !Array.isArray(raw.roles)) return null;
61
+
62
+ return {
63
+ definition: {
64
+ spec: raw.spec || 'preset/v1',
65
+ id: raw.id,
66
+ name: raw.name,
67
+ tagline: raw.tagline,
68
+ version: raw.version || '1.0.0',
69
+ description: raw.description,
70
+ author: raw.author,
71
+ category: raw.category,
72
+ industry: raw.industry,
73
+ stage: raw.stage,
74
+ use_case: raw.use_case,
75
+ roles: raw.roles,
76
+ knowledge_docs: raw.knowledge_docs,
77
+ skills_count: raw.skills_count,
78
+ pricing: raw.pricing,
79
+ tags: raw.tags,
80
+ languages: raw.languages,
81
+ stats: raw.stats,
82
+ wave_scoped: raw.wave_scoped,
83
+ },
84
+ path: presetDir,
85
+ isDefault: false,
86
+ };
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Load all presets from knowledge/presets/ + auto-generated default.
94
+ * Returns [default, ...installed] — default is always first.
95
+ */
96
+ export function loadPresets(companyRoot: string): LoadedPreset[] {
97
+ const presets: LoadedPreset[] = [];
98
+
99
+ // 1. Default preset (always present)
100
+ const defaultPreset = buildDefaultPreset(companyRoot);
101
+
102
+ // Check if _default.yaml exists with overrides
103
+ const defaultYamlPath = path.join(companyRoot, PRESETS_DIR, DEFAULT_PRESET_FILE);
104
+ if (fs.existsSync(defaultYamlPath)) {
105
+ try {
106
+ const raw = YAML.parse(fs.readFileSync(defaultYamlPath, 'utf-8')) as Partial<PresetDefinition>;
107
+ if (raw.name) defaultPreset.definition.name = raw.name;
108
+ if (raw.tagline) defaultPreset.definition.tagline = raw.tagline;
109
+ if (raw.description) defaultPreset.definition.description = raw.description;
110
+ } catch { /* ignore malformed _default.yaml */ }
111
+ }
112
+
113
+ presets.push(defaultPreset);
114
+
115
+ // 2. Installed presets from knowledge/presets/{name}/preset.yaml
116
+ const presetsDir = path.join(companyRoot, PRESETS_DIR);
117
+ if (fs.existsSync(presetsDir)) {
118
+ const entries = fs.readdirSync(presetsDir, { withFileTypes: true });
119
+ for (const entry of entries) {
120
+ if (!entry.isDirectory()) continue;
121
+ const preset = loadPresetFromDir(path.join(presetsDir, entry.name));
122
+ if (preset) presets.push(preset);
123
+ }
124
+ }
125
+
126
+ return presets;
127
+ }
128
+
129
+ /**
130
+ * Get preset summaries for TUI display.
131
+ */
132
+ export function getPresetSummaries(companyRoot: string): PresetSummary[] {
133
+ return loadPresets(companyRoot).map(p => ({
134
+ id: p.definition.id,
135
+ name: p.definition.name,
136
+ description: p.definition.description ?? p.definition.tagline,
137
+ rolesCount: p.definition.roles.length,
138
+ roles: p.definition.roles,
139
+ isDefault: p.isDefault,
140
+ }));
141
+ }
142
+
143
+ /**
144
+ * Find a specific preset by ID.
145
+ */
146
+ export function getPresetById(companyRoot: string, presetId: string): LoadedPreset | null {
147
+ const presets = loadPresets(companyRoot);
148
+ return presets.find(p => p.definition.id === presetId) ?? null;
149
+ }
@@ -0,0 +1,34 @@
1
+ /* ── Model Pricing ──────────────────────── */
2
+
3
+ export interface ModelPricing {
4
+ inputPer1M: number; // USD per 1M input tokens
5
+ outputPer1M: number; // USD per 1M output tokens
6
+ }
7
+
8
+ /**
9
+ * Anthropic model pricing table.
10
+ * Source: https://docs.anthropic.com/en/docs/about-claude/pricing
11
+ */
12
+ export const MODEL_PRICING: Record<string, ModelPricing> = {
13
+ // Sonnet 4 family
14
+ 'claude-sonnet-4-5': { inputPer1M: 3.00, outputPer1M: 15.00 },
15
+ 'claude-sonnet-4-20250514': { inputPer1M: 3.00, outputPer1M: 15.00 },
16
+ // Opus 4 family
17
+ 'claude-opus-4-6': { inputPer1M: 15.00, outputPer1M: 75.00 },
18
+ // Haiku 4.5 family
19
+ 'claude-haiku-4-5': { inputPer1M: 0.80, outputPer1M: 4.00 },
20
+ 'claude-haiku-4-5-20251001': { inputPer1M: 0.80, outputPer1M: 4.00 },
21
+ };
22
+
23
+ const DEFAULT_PRICING: ModelPricing = { inputPer1M: 3.00, outputPer1M: 15.00 };
24
+
25
+ /** Estimate cost in USD for a given token usage */
26
+ export function estimateCost(
27
+ inputTokens: number,
28
+ outputTokens: number,
29
+ model: string,
30
+ ): number {
31
+ const pricing = MODEL_PRICING[model] ?? DEFAULT_PRICING;
32
+ return (inputTokens * pricing.inputPer1M / 1_000_000)
33
+ + (outputTokens * pricing.outputPer1M / 1_000_000);
34
+ }