tycono 0.1.2 → 0.1.3

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.2",
3
+ "version": "0.1.3",
4
4
  "description": "Build an AI company. Watch them work.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,6 +24,7 @@ import { getAllActivities, completeActivity } from './services/activity-tracker.
24
24
  import { knowledgeRouter } from './routes/knowledge.js';
25
25
  import { preferencesRouter } from './routes/preferences.js';
26
26
  import { saveRouter } from './routes/save.js';
27
+ import { speechRouter } from './routes/speech.js';
27
28
  import { importKnowledge } from './services/knowledge-importer.js';
28
29
  import { AnthropicProvider, type LLMProvider } from './engine/llm-adapter.js';
29
30
  import { readConfig } from './services/company-config.js';
@@ -132,6 +133,7 @@ export function createHttpServer(): http.Server {
132
133
  app.use('/api/sessions', sessionsRouter);
133
134
  app.use('/api/knowledge', knowledgeRouter);
134
135
  app.use('/api/preferences', preferencesRouter);
136
+ app.use('/api/speech', speechRouter);
135
137
  app.use('/api/save', saveRouter);
136
138
 
137
139
  app.get('/api/health', (_req, res) => {
@@ -0,0 +1,171 @@
1
+ /**
2
+ * speech.ts — Ambient Speech LLM generation endpoint
3
+ *
4
+ * Generates contextual, persona-driven speech for idle roles.
5
+ * Uses Haiku for cost efficiency (~$0.0003/call).
6
+ */
7
+ import { Router, Request, Response, NextFunction } from 'express';
8
+ import { COMPANY_ROOT } from '../services/file-reader.js';
9
+ import { buildOrgTree } from '../engine/index.js';
10
+ import { AnthropicProvider } from '../engine/llm-adapter.js';
11
+
12
+ export const speechRouter = Router();
13
+
14
+ // Lazy-init LLM provider (Haiku for cost efficiency)
15
+ let llm: AnthropicProvider | null = null;
16
+ function getLLM(): AnthropicProvider {
17
+ if (!llm) {
18
+ llm = new AnthropicProvider({
19
+ model: process.env.SPEECH_MODEL || 'claude-haiku-4-5-20251001',
20
+ });
21
+ }
22
+ return llm;
23
+ }
24
+
25
+ /**
26
+ * POST /api/speech/generate
27
+ *
28
+ * Body: { roleId, context?: string, relationships?: Array<{ partnerId, familiarity }> }
29
+ * Returns: { speech: string, tokens: { input: number, output: number } }
30
+ */
31
+ speechRouter.post('/generate', async (req: Request, res: Response, next: NextFunction) => {
32
+ try {
33
+ const { roleId, context, relationships } = req.body as {
34
+ roleId: string;
35
+ context?: string;
36
+ relationships?: Array<{ partnerId: string; partnerName: string; familiarity: number }>;
37
+ };
38
+
39
+ if (!roleId) {
40
+ res.status(400).json({ error: 'roleId is required' });
41
+ return;
42
+ }
43
+
44
+ // Build org tree to get persona
45
+ const tree = buildOrgTree(COMPANY_ROOT);
46
+ const node = tree.nodes.get(roleId);
47
+ if (!node) {
48
+ res.status(404).json({ error: `Role not found: ${roleId}` });
49
+ return;
50
+ }
51
+
52
+ const persona = node.persona || `${node.name} (${node.level})`;
53
+ const relContext = relationships?.length
54
+ ? `\nColleague relationships:\n${relationships.map(r =>
55
+ `- ${r.partnerName}: familiarity ${r.familiarity}/100`
56
+ ).join('\n')}`
57
+ : '';
58
+
59
+ const systemPrompt = `You are ${node.name}, a ${node.level} employee at a tech company.
60
+ Your persona: ${persona}
61
+
62
+ Generate a brief, natural idle thought or mumble (1 sentence, max 30 characters in Korean).
63
+ This is what you'd say to yourself while sitting at your desk.
64
+ It should reflect your personality, current concerns, or professional interests.
65
+ Do NOT use quotes. Just output the raw sentence.
66
+ ${relContext}
67
+ ${context ? `\nCurrent situation: ${context}` : ''}`;
68
+
69
+ const provider = getLLM();
70
+ const response = await provider.chat(
71
+ systemPrompt,
72
+ [{ role: 'user', content: 'Generate one idle thought.' }],
73
+ );
74
+
75
+ const text = response.content
76
+ .filter(c => c.type === 'text')
77
+ .map(c => (c as { type: 'text'; text: string }).text)
78
+ .join('')
79
+ .trim()
80
+ .replace(/^["']|["']$/g, ''); // strip quotes if LLM adds them
81
+
82
+ res.json({
83
+ speech: text,
84
+ tokens: response.usage,
85
+ });
86
+ } catch (err) {
87
+ next(err);
88
+ }
89
+ });
90
+
91
+ /**
92
+ * POST /api/speech/conversation
93
+ *
94
+ * Body: { roleA, roleB, familiarity, context? }
95
+ * Returns: { turns: Array<{ speaker: string, text: string }>, tokens }
96
+ */
97
+ speechRouter.post('/conversation', async (req: Request, res: Response, next: NextFunction) => {
98
+ try {
99
+ const { roleA, roleB, familiarity, context } = req.body as {
100
+ roleA: string;
101
+ roleB: string;
102
+ familiarity: number;
103
+ context?: string;
104
+ };
105
+
106
+ if (!roleA || !roleB) {
107
+ res.status(400).json({ error: 'roleA and roleB are required' });
108
+ return;
109
+ }
110
+
111
+ const tree = buildOrgTree(COMPANY_ROOT);
112
+ const nodeA = tree.nodes.get(roleA);
113
+ const nodeB = tree.nodes.get(roleB);
114
+ if (!nodeA || !nodeB) {
115
+ res.status(404).json({ error: 'Role not found' });
116
+ return;
117
+ }
118
+
119
+ const famLevel = familiarity >= 80 ? 'best friends'
120
+ : familiarity >= 50 ? 'close colleagues'
121
+ : familiarity >= 20 ? 'coworkers'
122
+ : 'barely acquainted';
123
+
124
+ const relation = nodeA.reportsTo === roleB ? `${nodeA.name} reports to ${nodeB.name}`
125
+ : nodeB.reportsTo === roleA ? `${nodeB.name} reports to ${nodeA.name}`
126
+ : nodeA.level === 'c-level' && nodeB.level === 'c-level' ? 'C-level peers'
127
+ : 'colleagues';
128
+
129
+ const systemPrompt = `Generate a short office conversation between two employees (2-3 turns, each turn max 25 Korean characters).
130
+
131
+ ${nodeA.name} (${nodeA.level}): ${nodeA.persona || 'a professional'}
132
+ ${nodeB.name} (${nodeB.level}): ${nodeB.persona || 'a professional'}
133
+
134
+ They are ${relation}. Familiarity level: ${famLevel} (${familiarity}/100).
135
+ ${context ? `Context: ${context}` : ''}
136
+
137
+ Output as JSON array: [{"speaker":"A","text":"..."},{"speaker":"B","text":"..."}]
138
+ No markdown, no quotes around the JSON. Just the array.`;
139
+
140
+ const provider = getLLM();
141
+ const response = await provider.chat(
142
+ systemPrompt,
143
+ [{ role: 'user', content: 'Generate the conversation.' }],
144
+ );
145
+
146
+ const raw = response.content
147
+ .filter(c => c.type === 'text')
148
+ .map(c => (c as { type: 'text'; text: string }).text)
149
+ .join('')
150
+ .trim();
151
+
152
+ let turns: Array<{ speaker: string; text: string }>;
153
+ try {
154
+ turns = JSON.parse(raw);
155
+ } catch {
156
+ // Fallback: try to extract JSON from markdown code block
157
+ const match = raw.match(/\[[\s\S]*\]/);
158
+ turns = match ? JSON.parse(match[0]) : [
159
+ { speaker: 'A', text: '...' },
160
+ { speaker: 'B', text: '...' },
161
+ ];
162
+ }
163
+
164
+ res.json({
165
+ turns,
166
+ tokens: response.usage,
167
+ });
168
+ } catch (err) {
169
+ next(err);
170
+ }
171
+ });
@@ -15,9 +15,19 @@ export interface CharacterAppearance {
15
15
  shoeColor: string;
16
16
  }
17
17
 
18
+ export interface SpeechSettings {
19
+ /** 'template' = static pool only, 'llm' = AI generation, 'auto' = detect engine */
20
+ mode: 'template' | 'llm' | 'auto';
21
+ /** Interval between ambient speech in seconds */
22
+ intervalSec: number;
23
+ /** Daily budget for LLM speech in USD (0 = unlimited) */
24
+ dailyBudgetUsd: number;
25
+ }
26
+
18
27
  export interface Preferences {
19
28
  appearances: Record<string, CharacterAppearance>;
20
29
  theme: string;
30
+ speech?: SpeechSettings;
21
31
  }
22
32
 
23
33
  const CONFIG_DIR = '.tycono';
@@ -37,6 +47,7 @@ export function readPreferences(companyRoot: string): Preferences {
37
47
  return {
38
48
  appearances: data.appearances ?? {},
39
49
  theme: data.theme ?? 'default',
50
+ speech: data.speech ?? undefined,
40
51
  };
41
52
  } catch {
42
53
  return { ...DEFAULT, appearances: {} };
@@ -58,6 +69,9 @@ export function mergePreferences(companyRoot: string, partial: Partial<Preferenc
58
69
  ? { ...current.appearances, ...partial.appearances }
59
70
  : current.appearances,
60
71
  theme: partial.theme ?? current.theme,
72
+ speech: partial.speech !== undefined
73
+ ? { ...current.speech, ...partial.speech }
74
+ : current.speech,
61
75
  };
62
76
  writePreferences(companyRoot, merged);
63
77
  return merged;