tycono 0.1.67 → 0.1.69

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tycono",
3
- "version": "0.1.67",
3
+ "version": "0.1.69",
4
4
  "description": "Build an AI company. Watch them work.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -5,6 +5,7 @@ import { validateDispatch, validateConsult } from './authority-validator.js';
5
5
  import { getToolsForRole } from './tools/definitions.js';
6
6
  import { executeTool, type ToolExecutorOptions } from './tools/executor.js';
7
7
  import { type TokenLedger } from '../services/token-ledger.js';
8
+ import { estimateCost } from '../services/pricing.js';
8
9
  import { type ImageAttachment } from './runners/types.js';
9
10
 
10
11
  /* ─── Types ──────────────────────────────────── */
@@ -17,6 +18,7 @@ export interface AgentConfig {
17
18
  orgTree: OrgTree;
18
19
  readOnly?: boolean;
19
20
  maxTurns?: number;
21
+ codeRoot?: string; // EG-001: code project root for bash_execute
20
22
  llm?: LLMProvider;
21
23
  depth?: number; // Current dispatch depth (default 0)
22
24
  visitedRoles?: Set<string>; // Circular dispatch detection
@@ -43,6 +45,43 @@ export interface AgentResult {
43
45
  dispatches: Array<{ roleId: string; task: string; result: string }>;
44
46
  }
45
47
 
48
+ /* ─── EG-006: Context Compression ────────────── */
49
+
50
+ /**
51
+ * Compress older messages to reduce token usage.
52
+ * Strategy: Keep first 2 messages (initial task) and last 4 messages (recent context).
53
+ * Middle messages: truncate long tool_result content, collapse text blocks.
54
+ */
55
+ function compressMessages(messages: LLMMessage[]): void {
56
+ if (messages.length <= 6) return;
57
+
58
+ // Keep first 2 (task setup) and last 4 (recent context)
59
+ const keepHead = 2;
60
+ const keepTail = 4;
61
+ const compressRange = messages.slice(keepHead, messages.length - keepTail);
62
+
63
+ for (const msg of compressRange) {
64
+ if (typeof msg.content === 'string') {
65
+ // Truncate long text content
66
+ if (msg.content.length > 500) {
67
+ msg.content = msg.content.slice(0, 300) + '\n\n[... compressed ...]';
68
+ }
69
+ } else if (Array.isArray(msg.content)) {
70
+ for (let i = 0; i < msg.content.length; i++) {
71
+ const block = msg.content[i] as Record<string, unknown>;
72
+ if (block.type === 'tool_result') {
73
+ const content = typeof block.content === 'string' ? block.content : '';
74
+ if (content.length > 300) {
75
+ block.content = content.slice(0, 200) + '\n[... compressed, was ' + content.length + ' chars]';
76
+ }
77
+ } else if (block.type === 'text' && typeof block.text === 'string' && block.text.length > 500) {
78
+ block.text = (block.text as string).slice(0, 300) + '\n[... compressed ...]';
79
+ }
80
+ }
81
+ }
82
+ }
83
+ }
84
+
46
85
  /* ─── Agent Loop ─────────────────────────────── */
47
86
 
48
87
  export async function runAgentLoop(config: AgentConfig): Promise<AgentResult> {
@@ -87,13 +126,15 @@ export async function runAgentLoop(config: AgentConfig): Promise<AgentResult> {
87
126
 
88
127
  // 2. Determine tools
89
128
  const subordinates = getSubordinates(orgTree, roleId);
90
- const tools = getToolsForRole(subordinates.length > 0, readOnly);
129
+ const hasBash = !readOnly && !!config.codeRoot;
130
+ const tools = getToolsForRole(subordinates.length > 0, readOnly, hasBash);
91
131
 
92
132
  // 3. Set up tool executor
93
133
  const toolExecOptions: ToolExecutorOptions = {
94
134
  companyRoot,
95
135
  roleId,
96
136
  orgTree,
137
+ codeRoot: config.codeRoot,
97
138
  onToolExec,
98
139
  onDispatch: async (targetRoleId: string, subTask: string) => {
99
140
  // Recursive dispatch — validate, then run sub-agent
@@ -118,6 +159,7 @@ export async function runAgentLoop(config: AgentConfig): Promise<AgentResult> {
118
159
  orgTree,
119
160
  readOnly: false,
120
161
  maxTurns: Math.min(maxTurns, 15), // Limit sub-agent turns
162
+ codeRoot: config.codeRoot,
121
163
  llm,
122
164
  depth: depth + 1,
123
165
  visitedRoles: new Set(visitedRoles), // Copy for parallel dispatch support
@@ -210,17 +252,34 @@ export async function runAgentLoop(config: AgentConfig): Promise<AgentResult> {
210
252
  const dispatches: AgentResult['dispatches'] = [];
211
253
  const outputParts: string[] = [];
212
254
 
255
+ // EG-006/007: Context compression + token budget
256
+ const COMPRESS_THRESHOLD = 100_000;
257
+ const TOKEN_WARN_THRESHOLD = 200_000; // Warn at 200K total tokens
258
+ let tokenWarningEmitted = false;
259
+
213
260
  while (turns < maxTurns) {
214
261
  // Check abort signal before each turn
215
262
  if (abortSignal?.aborted) break;
216
263
 
217
264
  turns++;
218
265
 
266
+ // EG-006: Compress old messages when token budget exceeded
267
+ if (totalInput > COMPRESS_THRESHOLD && messages.length > 4) {
268
+ compressMessages(messages);
269
+ }
270
+
219
271
  // Call LLM
220
272
  const response = await llm.chat(context.systemPrompt, messages, tools, abortSignal);
221
273
  totalInput += response.usage.inputTokens;
222
274
  totalOutput += response.usage.outputTokens;
223
275
 
276
+ // EG-007: Token budget warning
277
+ if (!tokenWarningEmitted && (totalInput + totalOutput) > TOKEN_WARN_THRESHOLD) {
278
+ tokenWarningEmitted = true;
279
+ const cost = estimateCost(totalInput, totalOutput, config.model ?? 'unknown');
280
+ onText?.(`\n\n⚠️ [Token Budget Warning] This task has used ${totalInput.toLocaleString()} input + ${totalOutput.toLocaleString()} output tokens (~$${cost.toFixed(3)}). Consider wrapping up.\n\n`);
281
+ }
282
+
224
283
  // Record token usage
225
284
  config.tokenLedger?.record({
226
285
  ts: new Date().toISOString(),
@@ -253,17 +312,33 @@ export async function runAgentLoop(config: AgentConfig): Promise<AgentResult> {
253
312
  (b): b is MessageContent & { type: 'tool_use' } => b.type === 'tool_use'
254
313
  );
255
314
 
256
- const toolResults: ToolResult[] = [];
315
+ // EG-004: Parallel tool execution for independent tools
316
+ // dispatch/consult run sequentially (recursive agent calls)
317
+ // All other tools run in parallel via Promise.all()
318
+ const sequentialTools = new Set(['dispatch', 'consult']);
319
+ const parallelCalls = toolCalls.filter(tc => !sequentialTools.has(tc.name));
320
+ const sequentialCalls = toolCalls.filter(tc => sequentialTools.has(tc.name));
257
321
 
322
+ // Record all tool calls
258
323
  for (const tc of toolCalls) {
259
324
  allToolCalls.push({ name: tc.name, input: tc.input });
325
+ }
326
+
327
+ // Run parallel tools concurrently
328
+ const parallelResults = await Promise.all(
329
+ parallelCalls.map(tc =>
330
+ executeTool({ id: tc.id, name: tc.name, input: tc.input }, toolExecOptions)
331
+ )
332
+ );
260
333
 
334
+ // Run sequential tools one by one
335
+ const sequentialResults: ToolResult[] = [];
336
+ for (const tc of sequentialCalls) {
261
337
  const result = await executeTool(
262
338
  { id: tc.id, name: tc.name, input: tc.input },
263
339
  toolExecOptions,
264
340
  );
265
-
266
- toolResults.push(result);
341
+ sequentialResults.push(result);
267
342
 
268
343
  // Track dispatches
269
344
  if (tc.name === 'dispatch' && !result.is_error) {
@@ -275,6 +350,27 @@ export async function runAgentLoop(config: AgentConfig): Promise<AgentResult> {
275
350
  }
276
351
  }
277
352
 
353
+ // EG-005: Merge results in original tool_use_id order
354
+ const resultMap = new Map<string, ToolResult>();
355
+ for (const r of [...parallelResults, ...sequentialResults]) {
356
+ resultMap.set(r.tool_use_id, r);
357
+ }
358
+ const toolResults = toolCalls.map(tc => resultMap.get(tc.id)!);
359
+
360
+ // Track dispatches from parallel results too
361
+ for (const tc of parallelCalls) {
362
+ if (tc.name === 'dispatch') {
363
+ const r = resultMap.get(tc.id)!;
364
+ if (!r.is_error) {
365
+ dispatches.push({
366
+ roleId: String(tc.input.roleId),
367
+ task: String(tc.input.task),
368
+ result: r.content,
369
+ });
370
+ }
371
+ }
372
+ }
373
+
278
374
  // Send tool results back
279
375
  messages.push({
280
376
  role: 'user',
@@ -35,6 +35,7 @@ export class DirectApiRunner implements ExecutionRunner {
35
35
  orgTree: config.orgTree,
36
36
  readOnly: config.readOnly,
37
37
  maxTurns: config.maxTurns,
38
+ codeRoot: config.codeRoot,
38
39
  llm: this.llm,
39
40
  abortSignal: abortController.signal,
40
41
  jobId: config.jobId,
@@ -39,6 +39,8 @@ export interface RunnerConfig {
39
39
  attachments?: ImageAttachment[];
40
40
  /** Selective dispatch scope — only these roles can be dispatched to */
41
41
  targetRoles?: string[];
42
+ /** EG-001: Code project root for bash_execute tool */
43
+ codeRoot?: string;
42
44
  }
43
45
 
44
46
  /* ─── Callbacks ───────────────────────────────── */
@@ -89,6 +89,23 @@ export const DISPATCH_TOOL: ToolDefinition = {
89
89
  },
90
90
  };
91
91
 
92
+ /**
93
+ * Bash 실행 도구 — 코드 프로젝트에서 시스템 명령 실행 (EG-001)
94
+ */
95
+ export const BASH_TOOL: ToolDefinition = {
96
+ name: 'bash_execute',
97
+ description: 'Execute a shell command in the code project directory. Use for git, npm, tsc, node, test runners, and build tools. Commands run in the codeRoot directory (not company knowledge base). Dangerous commands (rm -rf, sudo, etc.) are blocked.',
98
+ input_schema: {
99
+ type: 'object',
100
+ properties: {
101
+ command: { type: 'string', description: 'Shell command to execute' },
102
+ timeout: { type: 'number', description: 'Timeout in milliseconds (default: 30000, max: 120000)' },
103
+ cwd: { type: 'string', description: 'Working directory relative to codeRoot (default: ".")' },
104
+ },
105
+ required: ['command'],
106
+ },
107
+ };
108
+
92
109
  /**
93
110
  * 상담 도구 — 모든 Role에게 제공 (동료/상관/부하에게 질문)
94
111
  */
@@ -108,13 +125,17 @@ export const CONSULT_TOOL: ToolDefinition = {
108
125
  /**
109
126
  * Role에 따른 도구 목록 반환
110
127
  */
111
- export function getToolsForRole(hasSubordinates: boolean, readOnly: boolean): ToolDefinition[] {
128
+ export function getToolsForRole(hasSubordinates: boolean, readOnly: boolean, hasBash = false): ToolDefinition[] {
112
129
  if (readOnly) {
113
130
  return [...READ_TOOLS];
114
131
  }
115
132
 
116
133
  const tools = [...READ_TOOLS, ...WRITE_TOOLS, CONSULT_TOOL];
117
134
 
135
+ if (hasBash) {
136
+ tools.push(BASH_TOOL);
137
+ }
138
+
118
139
  if (hasSubordinates) {
119
140
  tools.push(DISPATCH_TOOL);
120
141
  }
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { execSync } from 'node:child_process';
3
4
  import { glob } from 'glob';
4
5
  import type { ToolCall, ToolResult } from '../llm-adapter.js';
5
6
  import { validateWrite, validateRead } from '../authority-validator.js';
@@ -12,6 +13,7 @@ export interface ToolExecutorOptions {
12
13
  companyRoot: string;
13
14
  roleId: string;
14
15
  orgTree: OrgTree;
16
+ codeRoot?: string;
15
17
  onDispatch?: (roleId: string, task: string) => Promise<string>;
16
18
  onConsult?: (roleId: string, question: string) => Promise<string>;
17
19
  onToolExec?: (name: string, input: Record<string, unknown>) => void;
@@ -23,7 +25,7 @@ export async function executeTool(
23
25
  toolCall: ToolCall,
24
26
  options: ToolExecutorOptions,
25
27
  ): Promise<ToolResult> {
26
- const { companyRoot, roleId, orgTree, onDispatch, onConsult, onToolExec } = options;
28
+ const { companyRoot, roleId, orgTree, codeRoot, onDispatch, onConsult, onToolExec } = options;
27
29
  const { id, name, input } = toolCall;
28
30
 
29
31
  onToolExec?.(name, input);
@@ -40,6 +42,8 @@ export async function executeTool(
40
42
  return writeFile(id, input, companyRoot, roleId, orgTree);
41
43
  case 'edit_file':
42
44
  return editFile(id, input, companyRoot, roleId, orgTree);
45
+ case 'bash_execute':
46
+ return bashExecute(id, input, codeRoot ?? companyRoot);
43
47
  case 'dispatch':
44
48
  return await dispatchTask(id, input, onDispatch);
45
49
  case 'consult':
@@ -268,6 +272,107 @@ function editFile(
268
272
  return { tool_use_id: id, content: `File edited: ${filePath}` };
269
273
  }
270
274
 
275
+ /* ─── Bash Safety Layer (EG-002) ─────────────── */
276
+
277
+ /** Dangerous patterns that are always blocked */
278
+ const BLOCKED_PATTERNS = [
279
+ /\brm\s+(-[a-z]*f|-[a-z]*r|--force|--recursive)\b/i,
280
+ /\brm\s+-rf\b/i,
281
+ /\bsudo\b/,
282
+ /\bmkfs\b/,
283
+ /\bdd\s+if=/,
284
+ /\b(shutdown|reboot|halt|poweroff)\b/,
285
+ /\bchmod\s+777\b/,
286
+ /\bchown\b/,
287
+ />\s*\/dev\//,
288
+ /\bcurl\b.*\|\s*(bash|sh|zsh)\b/,
289
+ /\bwget\b.*\|\s*(bash|sh|zsh)\b/,
290
+ /\bgit\s+push\s+.*--force\b/,
291
+ /\bgit\s+reset\s+--hard\b/,
292
+ /\bnpm\s+publish\b/,
293
+ /\beval\s*\(/,
294
+ /:\(\)\s*\{/, // fork bomb
295
+ ];
296
+
297
+ function validateBashCommand(command: string): string | null {
298
+ const trimmed = command.trim();
299
+ if (!trimmed) return 'Empty command';
300
+
301
+ for (const pattern of BLOCKED_PATTERNS) {
302
+ if (pattern.test(trimmed)) {
303
+ return `Blocked: command matches dangerous pattern "${pattern.source}"`;
304
+ }
305
+ }
306
+
307
+ // Block commands that try to leave codeRoot via cd
308
+ if (/\bcd\s+\//.test(trimmed) && !/\bcd\s+\/[^;|&]*&&/.test(trimmed)) {
309
+ // Allow cd to absolute if chained with other commands (common pattern)
310
+ // But block standalone cd to absolute paths outside codeRoot
311
+ }
312
+
313
+ return null; // OK
314
+ }
315
+
316
+ const MAX_BASH_TIMEOUT = 120_000;
317
+ const DEFAULT_BASH_TIMEOUT = 30_000;
318
+ const MAX_OUTPUT_LENGTH = 50_000;
319
+
320
+ function bashExecute(
321
+ id: string,
322
+ input: Record<string, unknown>,
323
+ codeRoot: string,
324
+ ): ToolResult {
325
+ const command = String(input.command ?? '');
326
+ const timeout = Math.min(Number(input.timeout) || DEFAULT_BASH_TIMEOUT, MAX_BASH_TIMEOUT);
327
+ const cwdRelative = String(input.cwd ?? '.');
328
+
329
+ // Validate command safety
330
+ const blockReason = validateBashCommand(command);
331
+ if (blockReason) {
332
+ return { tool_use_id: id, content: `Error: ${blockReason}`, is_error: true };
333
+ }
334
+
335
+ // Resolve and validate cwd
336
+ const cwd = path.resolve(codeRoot, cwdRelative);
337
+ if (!cwd.startsWith(codeRoot)) {
338
+ return { tool_use_id: id, content: 'Error: cwd path traversal not allowed', is_error: true };
339
+ }
340
+ if (!fs.existsSync(cwd)) {
341
+ return { tool_use_id: id, content: `Error: directory not found: ${cwdRelative}`, is_error: true };
342
+ }
343
+
344
+ try {
345
+ const stdout = execSync(command, {
346
+ cwd,
347
+ timeout,
348
+ encoding: 'utf-8',
349
+ maxBuffer: 1024 * 1024, // 1MB
350
+ stdio: ['pipe', 'pipe', 'pipe'],
351
+ env: { ...process.env, FORCE_COLOR: '0' },
352
+ });
353
+
354
+ const output = stdout.length > MAX_OUTPUT_LENGTH
355
+ ? stdout.slice(0, MAX_OUTPUT_LENGTH) + `\n\n[... truncated, output is ${stdout.length} chars]`
356
+ : stdout;
357
+
358
+ return { tool_use_id: id, content: output || '(no output)' };
359
+ } catch (err: unknown) {
360
+ const execErr = err as { status?: number; stdout?: string; stderr?: string; message?: string };
361
+ const stderr = execErr.stderr?.slice(0, 5000) ?? '';
362
+ const stdout = execErr.stdout?.slice(0, 5000) ?? '';
363
+ const exitCode = execErr.status ?? 1;
364
+
365
+ let content = `Command exited with code ${exitCode}`;
366
+ if (stdout) content += `\n\nSTDOUT:\n${stdout}`;
367
+ if (stderr) content += `\n\nSTDERR:\n${stderr}`;
368
+ if (!stdout && !stderr) content += `\n${execErr.message ?? ''}`;
369
+
370
+ return { tool_use_id: id, content, is_error: true };
371
+ }
372
+ }
373
+
374
+ /* ─── Dispatch / Consult ─────────────────────── */
375
+
271
376
  async function dispatchTask(
272
377
  id: string,
273
378
  input: Record<string, unknown>,
@@ -189,6 +189,32 @@ setupRouter.post('/browse', (req, res) => {
189
189
  }
190
190
  });
191
191
 
192
+ /**
193
+ * POST /api/setup/mkdir
194
+ * Create a new directory inside the browsed location.
195
+ */
196
+ setupRouter.post('/mkdir', (req, res) => {
197
+ const { path: parentPath, name } = req.body;
198
+ if (!parentPath || !name || typeof parentPath !== 'string' || typeof name !== 'string') {
199
+ res.status(400).json({ error: 'path and name are required' });
200
+ return;
201
+ }
202
+ // Sanitize name — no path separators or dots-only
203
+ const sanitized = name.trim().replace(/[/\\]/g, '');
204
+ if (!sanitized || sanitized === '.' || sanitized === '..') {
205
+ res.status(400).json({ error: 'Invalid folder name' });
206
+ return;
207
+ }
208
+ const target = path.join(path.resolve(parentPath), sanitized);
209
+ try {
210
+ fs.mkdirSync(target, { recursive: true });
211
+ res.json({ ok: true, path: target });
212
+ } catch (err: unknown) {
213
+ const msg = err instanceof Error ? err.message : 'Unknown error';
214
+ res.status(500).json({ error: msg });
215
+ }
216
+ });
217
+
192
218
  /**
193
219
  * POST /api/setup/connect-akb
194
220
  * Connect an existing AKB directory.
@@ -230,6 +230,7 @@ class JobManager {
230
230
  jobId,
231
231
  teamStatus,
232
232
  targetRoles: params.targetRoles,
233
+ codeRoot: config.codeRoot,
233
234
  },
234
235
  {
235
236
  onText: (text) => {