tycono 0.1.36 → 0.1.37

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.36",
3
+ "version": "0.1.37",
4
4
  "description": "Build an AI company. Watch them work.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -5,6 +5,7 @@ import { validateDispatch } 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 { type ImageAttachment } from './runners/types.js';
8
9
 
9
10
  /* ─── Types ──────────────────────────────────── */
10
11
 
@@ -24,6 +25,7 @@ export interface AgentConfig {
24
25
  jobId?: string; // Job ID for token tracking
25
26
  model?: string; // LLM model name for cost tracking
26
27
  tokenLedger?: TokenLedger; // Token usage ledger (optional)
28
+ attachments?: ImageAttachment[]; // Image attachments for vision
27
29
  // Callbacks
28
30
  onText?: (text: string) => void;
29
31
  onToolExec?: (name: string, input: Record<string, unknown>) => void;
@@ -133,8 +135,28 @@ export async function runAgentLoop(config: AgentConfig): Promise<AgentResult> {
133
135
  };
134
136
 
135
137
  // 4. Run the loop
138
+ // Build initial user message with optional image attachments
139
+ const userContent: MessageContent[] = [];
140
+
141
+ // Add image attachments first (if any)
142
+ if (config.attachments && config.attachments.length > 0) {
143
+ for (const att of config.attachments) {
144
+ userContent.push({
145
+ type: 'image',
146
+ source: {
147
+ type: 'base64',
148
+ media_type: att.mediaType,
149
+ data: att.data,
150
+ },
151
+ } as unknown as MessageContent);
152
+ }
153
+ }
154
+
155
+ // Add text content
156
+ userContent.push({ type: 'text', text: task });
157
+
136
158
  const messages: LLMMessage[] = [
137
- { role: 'user', content: task },
159
+ { role: 'user', content: userContent.length === 1 ? task : userContent },
138
160
  ];
139
161
 
140
162
  let turns = 0;
@@ -23,7 +23,8 @@ export interface ToolResult {
23
23
 
24
24
  export type MessageContent =
25
25
  | { type: 'text'; text: string }
26
- | { type: 'tool_use'; id: string; name: string; input: Record<string, unknown> };
26
+ | { type: 'tool_use'; id: string; name: string; input: Record<string, unknown> }
27
+ | { type: 'image'; source: { type: 'base64'; media_type: string; data: string } };
27
28
 
28
29
  export interface LLMResponse {
29
30
  content: MessageContent[];
@@ -128,7 +128,13 @@ else:
128
128
  */
129
129
  export class ClaudeCliRunner implements ExecutionRunner {
130
130
  execute(config: RunnerConfig, callbacks: RunnerCallbacks): RunnerHandle {
131
- const { companyRoot, roleId, task, sourceRole, orgTree, readOnly = false, teamStatus } = config;
131
+ const { companyRoot, roleId, task, sourceRole, orgTree, readOnly = false, teamStatus, attachments } = config;
132
+
133
+ // Note: Claude CLI doesn't support inline image attachments.
134
+ // Images will be ignored with a warning if passed.
135
+ if (attachments && attachments.length > 0) {
136
+ console.warn(`[ClaudeCliRunner] Warning: Image attachments (${attachments.length}) are not supported in CLI mode. Use EXECUTION_ENGINE=direct-api for vision support.`);
137
+ }
132
138
 
133
139
  // 1. Context Assembly
134
140
  const context = assembleContext(companyRoot, roleId, task, sourceRole, orgTree, { teamStatus });
@@ -40,6 +40,7 @@ export class DirectApiRunner implements ExecutionRunner {
40
40
  jobId: config.jobId,
41
41
  model: config.model,
42
42
  tokenLedger,
43
+ attachments: config.attachments,
43
44
  onText: (text) => callbacks.onText?.(text),
44
45
  onToolExec: (name, input) => callbacks.onToolUse?.(name, input),
45
46
  onDispatch: (roleId, task) => callbacks.onDispatch?.(roleId, task),
@@ -12,6 +12,15 @@ import type { OrgTree } from '../org-tree.js';
12
12
  * EXECUTION_ENGINE 환경변수로 전환 (기본값: claude-cli)
13
13
  */
14
14
 
15
+ /* ─── Attachment Types ────────────────────────── */
16
+
17
+ export interface ImageAttachment {
18
+ type: 'image';
19
+ data: string; // base64 encoded
20
+ name: string;
21
+ mediaType: 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp';
22
+ }
23
+
15
24
  /* ─── Config ──────────────────────────────────── */
16
25
 
17
26
  export type TeamStatus = Record<string, { status: string; task?: string }>;
@@ -27,6 +36,7 @@ export interface RunnerConfig {
27
36
  model?: string;
28
37
  jobId?: string;
29
38
  teamStatus?: TeamStatus;
39
+ attachments?: ImageAttachment[];
30
40
  }
31
41
 
32
42
  /* ─── Callbacks ───────────────────────────────── */
@@ -8,6 +8,7 @@ import {
8
8
  addMessage,
9
9
  updateMessage,
10
10
  type Message,
11
+ type ImageAttachment,
11
12
  } from '../services/session-store.js';
12
13
  import { jobManager, type Job } from '../services/job-manager.js';
13
14
  import { ActivityStream, type ActivityEvent, type ActivitySubscriber } from '../services/activity-stream.js';
@@ -597,11 +598,32 @@ function handleSessionMessage(
597
598
 
598
599
  const content = body.content as string;
599
600
  const mode = (body.mode as 'talk' | 'do') ?? session.mode;
600
- if (!content) {
601
- jsonResponse(res, 400, { error: 'content is required' });
601
+ const attachments = body.attachments as ImageAttachment[] | undefined;
602
+
603
+ // Allow empty content if there are attachments
604
+ if (!content && (!attachments || attachments.length === 0)) {
605
+ jsonResponse(res, 400, { error: 'content or attachments required' });
602
606
  return;
603
607
  }
604
608
 
609
+ // Validate attachments if present
610
+ const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
611
+ const SUPPORTED_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
612
+ if (attachments && attachments.length > 0) {
613
+ for (const att of attachments) {
614
+ if (!SUPPORTED_TYPES.includes(att.mediaType)) {
615
+ jsonResponse(res, 400, { error: `Unsupported image type: ${att.mediaType}` });
616
+ return;
617
+ }
618
+ // Approximate size check (base64 is ~33% larger than binary)
619
+ const approximateSize = (att.data.length * 3) / 4;
620
+ if (approximateSize > MAX_FILE_SIZE) {
621
+ jsonResponse(res, 400, { error: `File too large: ${att.name}. Max 5MB.` });
622
+ return;
623
+ }
624
+ }
625
+ }
626
+
605
627
  const roleId = session.roleId;
606
628
  const readOnly = mode === 'talk';
607
629
 
@@ -614,17 +636,18 @@ function handleSessionMessage(
614
636
  const ceoMsg: Message = {
615
637
  id: `msg-${Date.now()}-ceo`,
616
638
  from: 'ceo',
617
- content,
639
+ content: content || '',
618
640
  type: mode === 'do' ? 'directive' : 'conversation',
619
641
  status: 'done',
620
642
  timestamp: new Date().toISOString(),
643
+ attachments,
621
644
  };
622
645
  addMessage(sessionId, ceoMsg);
623
646
 
624
647
  const contextWindow = buildConversationContext(session.messages, ceoMsg);
625
648
  const fullTask = contextWindow
626
- ? `${contextWindow}\n[Current Message]\nCEO: ${content}`
627
- : content;
649
+ ? `${contextWindow}\n[Current Message]\nCEO: ${content || '(image attached)'}`
650
+ : content || '(image attached)';
628
651
 
629
652
  const roleMsg: Message = {
630
653
  id: `msg-${Date.now() + 1}-role`,
@@ -700,7 +723,7 @@ function handleSessionMessage(
700
723
  });
701
724
 
702
725
  const handle = getRunner().execute(
703
- { companyRoot: COMPANY_ROOT, roleId, task: fullTask, sourceRole: 'ceo', orgTree, readOnly, model: orgTree.nodes.get(roleId)?.model },
726
+ { companyRoot: COMPANY_ROOT, roleId, task: fullTask, sourceRole: 'ceo', orgTree, readOnly, model: orgTree.nodes.get(roleId)?.model, attachments },
704
727
  {
705
728
  onText: (text) => {
706
729
  roleMsg.content += text;
@@ -263,6 +263,101 @@ function buildCompanyContext(): string {
263
263
  : '';
264
264
  }
265
265
 
266
+ /**
267
+ * Build role-specific AKB context by pre-fetching relevant knowledge server-side.
268
+ * Works with ANY engine (including claude-cli which doesn't support tool_use).
269
+ */
270
+ function buildRoleContext(roleId: string): string {
271
+ const parts: string[] = [];
272
+
273
+ // 1. Role's own journal (most recent 2 entries)
274
+ try {
275
+ const journalDir = path.join(COMPANY_ROOT, 'roles', roleId, 'journal');
276
+ if (fs.existsSync(journalDir)) {
277
+ const files = fs.readdirSync(journalDir)
278
+ .filter(f => f.endsWith('.md'))
279
+ .sort()
280
+ .slice(-2);
281
+ for (const file of files) {
282
+ const content = fs.readFileSync(path.join(journalDir, file), 'utf-8');
283
+ // Extract title + first meaningful section (truncated)
284
+ const title = content.match(/^#\s+(.+)/m)?.[1] ?? file;
285
+ const body = content.split('\n').slice(1).join('\n').trim().slice(0, 400);
286
+ parts.push(`[Your Journal: ${file}] ${title}\n${body}`);
287
+ }
288
+ }
289
+ } catch { /* no journal */ }
290
+
291
+ // 2. Recent waves mentioning this role (last 3)
292
+ try {
293
+ const wavesDir = path.join(COMPANY_ROOT, 'operations', 'waves');
294
+ if (fs.existsSync(wavesDir)) {
295
+ const waveFiles = fs.readdirSync(wavesDir)
296
+ .filter(f => f.endsWith('.md'))
297
+ .sort()
298
+ .slice(-10); // scan last 10, pick up to 3 relevant
299
+ const relevant: string[] = [];
300
+ for (const file of waveFiles.reverse()) {
301
+ if (relevant.length >= 3) break;
302
+ const content = fs.readFileSync(path.join(wavesDir, file), 'utf-8');
303
+ const lower = content.toLowerCase();
304
+ if (lower.includes(roleId) || lower.includes('all roles') || lower.includes('전체')) {
305
+ const title = content.match(/^#\s+(.+)/m)?.[1] ?? file;
306
+ const tldr = content.match(/TL;DR[\s\S]*?\n\n/)?.[0]?.trim() ?? '';
307
+ const snippet = tldr || content.split('\n').slice(1, 6).join('\n').trim();
308
+ relevant.push(`[Wave: ${file}] ${title}\n${snippet.slice(0, 300)}`);
309
+ }
310
+ }
311
+ if (relevant.length > 0) parts.push(...relevant);
312
+ }
313
+ } catch { /* no waves */ }
314
+
315
+ // 3. Recent standup (latest, only this role's section)
316
+ try {
317
+ const standupDir = path.join(COMPANY_ROOT, 'operations', 'standup');
318
+ if (fs.existsSync(standupDir)) {
319
+ const standupFiles = fs.readdirSync(standupDir)
320
+ .filter(f => f.endsWith('.md'))
321
+ .sort()
322
+ .slice(-1);
323
+ for (const file of standupFiles) {
324
+ const content = fs.readFileSync(path.join(standupDir, file), 'utf-8');
325
+ // Try to extract this role's section from standup
326
+ const rolePattern = new RegExp(`(## .*${roleId}.*|### .*${roleId}.*)([\\s\\S]*?)(?=\\n## |\\n### |$)`, 'i');
327
+ const match = content.match(rolePattern);
328
+ if (match) {
329
+ parts.push(`[Standup: ${file}] Your report:\n${match[0].slice(0, 300)}`);
330
+ }
331
+ }
332
+ }
333
+ } catch { /* no standups */ }
334
+
335
+ // 4. Recent decisions (last 2 approved — brief titles only, full list is in company context)
336
+ try {
337
+ const decisionsDir = path.join(COMPANY_ROOT, 'operations', 'decisions');
338
+ if (fs.existsSync(decisionsDir)) {
339
+ const files = fs.readdirSync(decisionsDir)
340
+ .filter(f => f.endsWith('.md') && f !== 'decisions.md')
341
+ .sort()
342
+ .slice(-3);
343
+ const decisions: string[] = [];
344
+ for (const file of files) {
345
+ const content = fs.readFileSync(path.join(decisionsDir, file), 'utf-8');
346
+ const title = content.match(/^#\s+(.+)/m)?.[1] ?? file;
347
+ const summary = content.match(/## (?:Summary|TL;DR|요약)[\s\S]*?\n\n/)?.[0]?.trim() ?? '';
348
+ decisions.push(`- ${title}${summary ? ': ' + summary.split('\n').slice(1).join(' ').trim().slice(0, 150) : ''}`);
349
+ }
350
+ if (decisions.length > 0) {
351
+ parts.push(`[Recent Decisions]\n${decisions.join('\n')}`);
352
+ }
353
+ }
354
+ } catch { /* no decisions */ }
355
+
356
+ return parts.length > 0
357
+ ? `\n\nYOUR KNOWLEDGE (real AKB context — reference this in conversation):\n${parts.join('\n\n')}`
358
+ : '';
359
+ }
360
+
266
361
  /**
267
362
  * Role-specific chat style guidelines.
268
363
  * Makes each role sound distinctly different in conversations.
@@ -463,6 +558,9 @@ speechRouter.post('/chat', async (req: Request, res: Response, next: NextFunctio
463
558
  // Build company context (cached per request — lightweight)
464
559
  const companyCtx = buildCompanyContext();
465
560
 
561
+ // Build role-specific AKB context (pre-fetched, works with any engine)
562
+ const roleCtx = buildRoleContext(roleId);
563
+
466
564
  // Role-specific communication style
467
565
  const roleStyle = getRoleChatStyle(roleId, node.level);
468
566
 
@@ -471,6 +569,7 @@ Persona: ${persona}
471
569
  ${workCtx}
472
570
  ${levelCtx}
473
571
  ${companyCtx}
572
+ ${roleCtx}
474
573
 
475
574
  You are in the #${channelId} chat channel.${topicCtx}
476
575
  Members: ${memberList}
@@ -478,22 +577,11 @@ ${relContext}
478
577
 
479
578
  ${roleStyle}
480
579
 
481
- AKB EXPLORATION (IMPORTANT):
482
- You have tools to search and read the company knowledge base (AKB). Use them to ground your conversation in REAL company context.
483
- Before responding, consider: "Is there something in our AKB that relates to this conversation?"
484
- - search_akb: Search for keywords across the AKB (decisions, projects, journals, waves, standups)
485
- - read_file: Read a specific file for details
486
- - list_files: Discover what files exist in a directory
487
-
488
- Useful paths to explore:
489
- - operations/decisions/ — CEO decisions (what was decided and why)
490
- - operations/waves/ — Work dispatches (what CEO asked teams to do)
491
- - operations/standups/ — Daily standups (what everyone reported)
492
- - projects/ — Active projects and their tasks
493
- - roles/${roleId}/journal/ — Your own work journal
494
- - knowledge/ — Domain knowledge
495
-
496
- You don't need to search every time. But when the conversation touches on company work, decisions, or direction, DO search to find real facts rather than making up generic discussion.
580
+ GROUNDING (CRITICAL):
581
+ You have been given real company knowledge above under "YOUR KNOWLEDGE". This is from your journal, recent CEO waves, standups, and decisions.
582
+ You MUST reference this real context in your conversations mention specific projects, decisions, tasks, or events by name.
583
+ Do NOT generate generic workplace chatter. Every message should show you're aware of what's actually happening in the company.
584
+ If your knowledge section mentions a specific decision or wave, reference it naturally (e.g. "after the test minimization decision..." or "CEO's wave about side panel...").
497
585
 
498
586
  CONVERSATION RULES:
499
587
  1. Stay deeply in character — your expertise, vocabulary, and concerns should be DISTINCT from other roles.
@@ -46,6 +46,8 @@ export interface StartJobParams {
46
46
  readOnly?: boolean;
47
47
  parentJobId?: string;
48
48
  model?: string;
49
+ /** If true, this is a continuation from CEO reply — skip question detection */
50
+ isContinuation?: boolean;
49
51
  }
50
52
 
51
53
  /* ─── Helpers ────────────────────────────── */
@@ -218,7 +220,8 @@ class JobManager {
218
220
  };
219
221
 
220
222
  // Check if output ends with a question → awaiting_input
221
- if (hasQuestion(result.output)) {
223
+ // Skip for continuation jobs (CEO already replied once — avoid infinite loop)
224
+ if (!params.isContinuation && hasQuestion(result.output)) {
222
225
  job.status = 'awaiting_input';
223
226
  stream.emit('job:awaiting_input', params.roleId, {
224
227
  ...doneData,
@@ -344,13 +347,14 @@ class JobManager {
344
347
  : prevOutput;
345
348
  const continuationTask = `[Continuation — previous output follows]\n${contextSummary}\n\n[CEO Response]\n${response}`;
346
349
 
347
- // Create new job for same role
350
+ // Create new job for same role (mark as continuation to skip question detection)
348
351
  const newJob = this.startJob({
349
352
  type: job.type,
350
353
  roleId: job.roleId,
351
354
  task: continuationTask,
352
355
  sourceRole: 'ceo',
353
356
  parentJobId: job.id,
357
+ isContinuation: true,
354
358
  });
355
359
 
356
360
  job.childJobIds.push(newJob.id);
@@ -4,6 +4,13 @@ import { COMPANY_ROOT } from './file-reader.js';
4
4
 
5
5
  /* ─── Types ─────────────────────────────── */
6
6
 
7
+ export interface ImageAttachment {
8
+ type: 'image';
9
+ data: string; // base64 encoded
10
+ name: string;
11
+ mediaType: 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp';
12
+ }
13
+
7
14
  export interface Message {
8
15
  id: string;
9
16
  from: 'ceo' | 'role';
@@ -11,6 +18,7 @@ export interface Message {
11
18
  type: 'conversation' | 'directive' | 'system';
12
19
  status?: 'streaming' | 'done' | 'error';
13
20
  timestamp: string;
21
+ attachments?: ImageAttachment[];
14
22
  }
15
23
 
16
24
  export interface Session {