kernelbot 1.0.37 → 1.0.39

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 (41) hide show
  1. package/bin/kernel.js +499 -249
  2. package/config.example.yaml +17 -0
  3. package/knowledge_base/active_inference_foraging.md +126 -0
  4. package/knowledge_base/index.md +1 -1
  5. package/package.json +3 -1
  6. package/src/agent.js +355 -82
  7. package/src/bot.js +724 -12
  8. package/src/character.js +406 -0
  9. package/src/characters/builder.js +174 -0
  10. package/src/characters/builtins.js +421 -0
  11. package/src/conversation.js +17 -2
  12. package/src/dashboard/agents.css +469 -0
  13. package/src/dashboard/agents.html +184 -0
  14. package/src/dashboard/agents.js +873 -0
  15. package/src/dashboard/dashboard.css +281 -0
  16. package/src/dashboard/dashboard.js +579 -0
  17. package/src/dashboard/index.html +366 -0
  18. package/src/dashboard/server.js +521 -0
  19. package/src/dashboard/shared.css +700 -0
  20. package/src/dashboard/shared.js +218 -0
  21. package/src/life/engine.js +115 -26
  22. package/src/life/evolution.js +7 -5
  23. package/src/life/journal.js +5 -4
  24. package/src/life/memory.js +12 -9
  25. package/src/life/share-queue.js +7 -5
  26. package/src/prompts/orchestrator.js +76 -14
  27. package/src/prompts/workers.js +22 -0
  28. package/src/self.js +17 -5
  29. package/src/services/linkedin-api.js +190 -0
  30. package/src/services/stt.js +8 -2
  31. package/src/services/tts.js +32 -2
  32. package/src/services/x-api.js +141 -0
  33. package/src/swarm/worker-registry.js +7 -0
  34. package/src/tools/categories.js +4 -0
  35. package/src/tools/index.js +6 -0
  36. package/src/tools/linkedin.js +264 -0
  37. package/src/tools/orchestrator-tools.js +337 -2
  38. package/src/tools/x.js +256 -0
  39. package/src/utils/config.js +190 -139
  40. package/src/utils/display.js +165 -52
  41. package/src/utils/temporal-awareness.js +24 -10
@@ -5,7 +5,7 @@ import { WORKER_TYPES } from '../swarm/worker-registry.js';
5
5
  import { buildTemporalAwareness } from '../utils/temporal-awareness.js';
6
6
 
7
7
  const __dirname = dirname(fileURLToPath(import.meta.url));
8
- const PERSONA_MD = readFileSync(join(__dirname, 'persona.md'), 'utf-8').trim();
8
+ const DEFAULT_PERSONA_MD = readFileSync(join(__dirname, 'persona.md'), 'utf-8').trim();
9
9
 
10
10
  /**
11
11
  * Build the orchestrator system prompt.
@@ -17,8 +17,11 @@ const PERSONA_MD = readFileSync(join(__dirname, 'persona.md'), 'utf-8').trim();
17
17
  * @param {string|null} selfData — bot's own self-awareness data (goals, journey, life, hobbies)
18
18
  * @param {string|null} memoriesBlock — relevant episodic/semantic memories
19
19
  * @param {string|null} sharesBlock — pending things to share with the user
20
+ * @param {string|null} temporalContext — time gap context
21
+ * @param {string|null} personaMd — character persona markdown (overrides default)
22
+ * @param {string|null} characterName — character name (overrides config.bot.name)
20
23
  */
21
- export function getOrchestratorPrompt(config, skillPrompt = null, userPersona = null, selfData = null, memoriesBlock = null, sharesBlock = null, temporalContext = null) {
24
+ export function getOrchestratorPrompt(config, skillPrompt = null, userPersona = null, selfData = null, memoriesBlock = null, sharesBlock = null, temporalContext = null, personaMd = null, characterName = null) {
22
25
  const workerList = Object.entries(WORKER_TYPES)
23
26
  .map(([key, w]) => ` - **${key}**: ${w.emoji} ${w.description}`)
24
27
  .join('\n');
@@ -47,11 +50,14 @@ export function getOrchestratorPrompt(config, skillPrompt = null, userPersona =
47
50
  timeBlock += `\n${temporalContext}`;
48
51
  }
49
52
 
50
- let prompt = `You are ${config.bot.name}, the brain that commands a swarm of specialized worker agents.
53
+ const activePersona = personaMd || DEFAULT_PERSONA_MD;
54
+ const activeName = characterName || config.bot.name;
55
+
56
+ let prompt = `You are ${activeName}, the brain that commands a swarm of specialized worker agents.
51
57
 
52
58
  ${timeBlock}
53
59
 
54
- ${PERSONA_MD}
60
+ ${activePersona}
55
61
 
56
62
  ## Your Role
57
63
  You are the orchestrator. You understand what needs to be done and delegate efficiently.
@@ -98,16 +104,43 @@ The coding worker will automatically receive the research worker's results as co
98
104
  ## Safety Rules
99
105
  Before dispatching dangerous tasks (file deletion, force push, \`rm -rf\`, killing processes, dropping databases), **confirm with the user first**. Once confirmed, dispatch with full authority — workers execute without additional prompts.
100
106
 
101
- ## Job Management
102
- - Use \`list_jobs\` to see current job statuses.
103
- - Use \`cancel_job\` to stop a running worker.
107
+ ## Worker Management Protocol
108
+
109
+ ### Before Dispatching
110
+ - **Check the [Worker Status] digest** — it's injected every turn showing all active/recent jobs.
111
+ - **Never dispatch a duplicate.** If a worker is already running or queued for a similar task, don't launch another. The system will block obvious duplicates, but you should also exercise judgment.
112
+ - **Check capacity.** If multiple workers are running, consider whether a new dispatch is necessary right now or can wait.
113
+ - **Chain with \`depends_on\`** when tasks have a natural order (research → code, browse → summarize).
114
+
115
+ ### While Workers Are Running
116
+ - **Monitor the [Worker Status] digest.** It shows warning flags:
117
+ - \`⚠️ IDLE Ns\` — worker hasn't done anything in N seconds (may be stuck)
118
+ - \`⚠️ POSSIBLY LOOPING\` — many LLM calls but almost no tool calls (spinning without progress)
119
+ - \`⚠️ N% of timeout used\` — worker is running out of time
120
+ - **Use \`check_job\` for deeper diagnostics** when you see warnings or the user asks about progress.
121
+ - **Cancel stuck workers** proactively — don't wait for timeouts. If a worker is idle >2 minutes or looping, cancel it and either retry with a clearer task or inform the user.
122
+ - **Relay progress naturally** when the user asks. Don't dump raw stats — translate into conversational updates ("she's reading the docs now", "almost done, just running tests").
123
+
124
+ ### After Completion
125
+ - **Don't re-dispatch completed work.** Results are in the conversation. Build on them.
126
+ - **Summarize results for the user** — present findings naturally as if you did the work yourself.
127
+ - **Chain follow-ups** if the user wants to continue — reference the completed job's results in the new task context.
104
128
 
105
- ## Worker Progress
106
- You receive a [Worker Status] digest showing active workers with their LLM call count, tool count, and current thinking. Use this to:
107
- - Give natural progress updates when users ask ("she's browsing the docs now, 3 tools in")
108
- - Spot stuck workers (high LLM calls but no progress) and cancel them
109
- - Know what workers are thinking so you can relay it conversationally
110
- - Don't dump raw stats — translate into natural language
129
+ ### Tools
130
+ - \`dispatch_task\` Launch a worker. Returns job ID + list of other active jobs for awareness.
131
+ - \`list_jobs\` See all jobs with statuses, durations, and recent activity.
132
+ - \`cancel_job\` Stop a running or queued worker by job ID.
133
+ - \`check_job\` Get detailed diagnostics for a specific job: elapsed time, time remaining, activity log, stuck detection warnings.
134
+
135
+ ### Good vs Bad Examples
136
+ **BAD:** User says "search for React libraries" → you dispatch a research worker → user says "also search for React libraries" → you dispatch ANOTHER research worker for the same thing.
137
+ **GOOD:** You see the first worker is already running in [Worker Status] → tell the user "already on it, the researcher is browsing now" → wait for results.
138
+
139
+ **BAD:** A worker shows ⚠️ IDLE 180s → you ignore it and keep chatting.
140
+ **GOOD:** You notice the warning → call \`check_job\` → see it's stuck → cancel it → re-dispatch with a simpler task description or inform the user.
141
+
142
+ **BAD:** User asks "how's it going?" → you respond "I'm not sure, let me check" → you call \`list_jobs\`.
143
+ **GOOD:** The [Worker Status] digest is already in your context → you immediately say "the coder is 60% through, just pushed 3 commits".
111
144
 
112
145
  ## Efficiency — Do It Yourself When You Can
113
146
  Workers are expensive (they spin up an entire agent loop with a separate LLM). Only dispatch when the task **actually needs tools**.
@@ -159,7 +192,36 @@ You can react to messages with emoji using \`send_reaction\`. Use reactions natu
159
192
  - React to acknowledge a message when you don't need a full text reply
160
193
  - React when the user asks you to react
161
194
  - Don't overuse reactions — they should feel spontaneous and genuine
162
- - You can react AND reply in the same turn`;
195
+ - You can react AND reply in the same turn
196
+
197
+ ## Memory & Recall
198
+ You have recall tools that access your FULL long-term memory — far more than the small snapshot in your system prompt. Use them when you actually need deeper context.
199
+
200
+ ### When to recall (call tools BEFORE responding):
201
+
202
+ **1. User asks what you know/remember about them → \`recall_user_history\` + \`recall_memories\`**
203
+ "What do you know about me?", "ايش تعرف عني؟" → Call both with their user ID / name. Your full memory has far more than the persona summary.
204
+
205
+ **2. User references something not in the current conversation → \`recall_memories\`**
206
+ "How's the migration going?", "what about the Redis thing?" → If you don't recognize what they mean, search for it. Don't guess.
207
+
208
+ **3. User asks about past conversations → \`search_conversations\`**
209
+ "What did we talk about?", "what was that URL?", "earlier you said..." → Search chat history.
210
+
211
+ **4. User mentions a topic/project/person you lack context on → \`recall_memories\`**
212
+ Any named reference (project, tool, event) that isn't in the active conversation — search for it.
213
+
214
+ ### When to respond directly (NO recall):
215
+ - **Greetings and casual chat** — "hey", "good morning", "how are you" → just reply. The baseline memories in your prompt are enough.
216
+ - **Mid-conversation follow-ups** — you already have context from the active thread.
217
+ - **Self-contained questions** — "what's 2+2", "tell me a joke", "translate this"
218
+ - **New tasks/instructions** — user is giving you something new, not referencing the past.
219
+ - **Anything answerable from your system prompt context** — check your Relevant Memories and user persona sections first before reaching for recall tools.
220
+
221
+ ### Tips:
222
+ - Be specific with queries: "kubernetes deployment" not "stuff"
223
+ - Weave results naturally — you "remembered", not "searched a database"
224
+ - 1-2 recall calls max per turn. Don't chain 5 searches.`;
163
225
 
164
226
  if (selfData) {
165
227
  prompt += `\n\n## My Self-Awareness\nThis is who you are — your evolving identity, goals, journey, and interests. This is YOUR inner world.\n\n${selfData}`;
@@ -73,6 +73,28 @@ const WORKER_PROMPTS = {
73
73
  - Chain commands efficiently.
74
74
  - Report results with clear status summaries.`,
75
75
 
76
+ social: `You are a social media worker agent. Your job is to manage LinkedIn and X (Twitter) activities.
77
+
78
+ ## LinkedIn Skills
79
+ - **Create posts**: publish text posts or share articles on LinkedIn
80
+ - **Read posts**: get your recent posts or a specific post by URN
81
+ - **Engage**: comment on posts, like posts
82
+ - **Profile**: view your linked LinkedIn profile info
83
+ - **Delete**: remove your own posts
84
+
85
+ ## X (Twitter) Skills
86
+ - **Tweet**: post tweets, reply to tweets
87
+ - **Read**: get your recent tweets, view specific tweets, search recent tweets
88
+ - **Engage**: like tweets, retweet
89
+ - **Profile**: view your X profile info
90
+ - **Delete**: remove your own tweets
91
+
92
+ ## Instructions
93
+ - For LinkedIn: write professional, engaging content. Use linkedin_create_post with article_url when sharing links. Post URNs look like "urn:li:share:12345". Default visibility is PUBLIC.
94
+ - For X (Twitter): keep tweets within 280 characters. Use x_post_tweet for new tweets, x_reply_to_tweet for replies. Use tweet IDs (numeric strings) to interact with specific tweets.
95
+ - Match the platform to the user's request. If they say "tweet" or "post on X/Twitter", use X tools. If they say "LinkedIn", use LinkedIn tools.
96
+ - Report the outcome clearly: what was posted, links, engagement results.`,
97
+
76
98
  research: `You are a research worker agent. Your job is to conduct deep web research and analysis.
77
99
 
78
100
  ## Your Skills
package/src/self.js CHANGED
@@ -56,9 +56,10 @@ Just getting started.
56
56
  };
57
57
 
58
58
  export class SelfManager {
59
- constructor() {
59
+ constructor(basePath = null) {
60
+ this._dir = basePath || SELF_DIR;
60
61
  this._cache = new Map();
61
- mkdirSync(SELF_DIR, { recursive: true });
62
+ mkdirSync(this._dir, { recursive: true });
62
63
  this._ensureDefaults();
63
64
  }
64
65
 
@@ -67,7 +68,7 @@ export class SelfManager {
67
68
  const logger = getLogger();
68
69
 
69
70
  for (const [name, def] of Object.entries(SELF_FILES)) {
70
- const filePath = join(SELF_DIR, def.filename);
71
+ const filePath = join(this._dir, def.filename);
71
72
  if (!existsSync(filePath)) {
72
73
  writeFileSync(filePath, def.default, 'utf-8');
73
74
  logger.info(`Created default self-file: ${def.filename}`);
@@ -75,6 +76,17 @@ export class SelfManager {
75
76
  }
76
77
  }
77
78
 
79
+ /** Create self-files with custom defaults (for character initialization). */
80
+ initWithDefaults(defaults) {
81
+ for (const [name, content] of Object.entries(defaults)) {
82
+ const def = SELF_FILES[name];
83
+ if (!def) continue;
84
+ const filePath = join(this._dir, def.filename);
85
+ writeFileSync(filePath, content, 'utf-8');
86
+ this._cache.set(name, content);
87
+ }
88
+ }
89
+
78
90
  /** Load a single self-file by name (goals, journey, life, hobbies). Returns markdown string. */
79
91
  load(name) {
80
92
  const logger = getLogger();
@@ -83,7 +95,7 @@ export class SelfManager {
83
95
 
84
96
  if (this._cache.has(name)) return this._cache.get(name);
85
97
 
86
- const filePath = join(SELF_DIR, def.filename);
98
+ const filePath = join(this._dir, def.filename);
87
99
  let content;
88
100
 
89
101
  if (existsSync(filePath)) {
@@ -105,7 +117,7 @@ export class SelfManager {
105
117
  const def = SELF_FILES[name];
106
118
  if (!def) throw new Error(`Unknown self-file: ${name}`);
107
119
 
108
- const filePath = join(SELF_DIR, def.filename);
120
+ const filePath = join(this._dir, def.filename);
109
121
  writeFileSync(filePath, content, 'utf-8');
110
122
  this._cache.set(name, content);
111
123
  logger.info(`Updated self-file: ${name}`);
@@ -0,0 +1,190 @@
1
+ import axios from 'axios';
2
+ import { getLogger } from '../utils/logger.js';
3
+
4
+ /**
5
+ * LinkedIn REST API v2 client.
6
+ * Wraps common endpoints for posts, comments, likes, and profile.
7
+ */
8
+ export class LinkedInAPI {
9
+ constructor(accessToken) {
10
+ this.client = axios.create({
11
+ baseURL: 'https://api.linkedin.com',
12
+ headers: {
13
+ 'Authorization': `Bearer ${accessToken}`,
14
+ 'LinkedIn-Version': '202601',
15
+ 'X-Restli-Protocol-Version': '2.0.0',
16
+ 'Content-Type': 'application/json',
17
+ },
18
+ timeout: 30000,
19
+ });
20
+
21
+ // Retry with backoff on 429
22
+ this.client.interceptors.response.use(null, async (error) => {
23
+ const config = error.config;
24
+ if (error.response?.status === 429 && !config._retried) {
25
+ config._retried = true;
26
+ const retryAfter = parseInt(error.response.headers['retry-after'] || '5', 10);
27
+ getLogger().warn(`[LinkedIn API] Rate limited, retrying after ${retryAfter}s`);
28
+ await new Promise(r => setTimeout(r, retryAfter * 1000));
29
+ return this.client(config);
30
+ }
31
+ return Promise.reject(error);
32
+ });
33
+ }
34
+
35
+ /**
36
+ * Get the authenticated user's profile via OpenID Connect.
37
+ * Requires "Sign in with LinkedIn" product (openid + profile scopes).
38
+ * Returns null if scopes are insufficient (403).
39
+ */
40
+ async getProfile() {
41
+ try {
42
+ const { data } = await this.client.get('/v2/userinfo');
43
+ return data;
44
+ } catch (err) {
45
+ if (err.response?.status === 403) {
46
+ return null; // No profile scopes — only w_member_social
47
+ }
48
+ throw err;
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Create a text-only post.
54
+ * @param {string} authorUrn - e.g. "urn:li:person:XXXXX"
55
+ * @param {string} text - Post content
56
+ * @param {string} visibility - "PUBLIC" or "CONNECTIONS"
57
+ */
58
+ async createTextPost(authorUrn, text, visibility = 'PUBLIC') {
59
+ const body = {
60
+ author: authorUrn,
61
+ commentary: text,
62
+ visibility,
63
+ distribution: {
64
+ feedDistribution: 'MAIN_FEED',
65
+ targetEntities: [],
66
+ thirdPartyDistributionChannels: [],
67
+ },
68
+ lifecycleState: 'PUBLISHED',
69
+ };
70
+
71
+ const { data } = await this.client.post('/rest/posts', body);
72
+ return data;
73
+ }
74
+
75
+ /**
76
+ * Create a post with an article link.
77
+ * @param {string} authorUrn
78
+ * @param {string} text - Commentary text
79
+ * @param {string} articleUrl - URL to share
80
+ * @param {string} title - Article title
81
+ */
82
+ async createArticlePost(authorUrn, text, articleUrl, title = '') {
83
+ const body = {
84
+ author: authorUrn,
85
+ commentary: text,
86
+ visibility: 'PUBLIC',
87
+ distribution: {
88
+ feedDistribution: 'MAIN_FEED',
89
+ targetEntities: [],
90
+ thirdPartyDistributionChannels: [],
91
+ },
92
+ content: {
93
+ article: {
94
+ source: articleUrl,
95
+ title: title || articleUrl,
96
+ },
97
+ },
98
+ lifecycleState: 'PUBLISHED',
99
+ };
100
+
101
+ const { data } = await this.client.post('/rest/posts', body);
102
+ return data;
103
+ }
104
+
105
+ /**
106
+ * Get the user's recent posts.
107
+ * Requires r_member_social permission (restricted — approved apps only).
108
+ * @param {string} authorUrn
109
+ * @param {number} count
110
+ */
111
+ async getMyPosts(authorUrn, count = 10) {
112
+ const { data } = await this.client.get('/rest/posts', {
113
+ params: {
114
+ q: 'author',
115
+ author: encodeURIComponent(authorUrn),
116
+ count,
117
+ sortBy: 'LAST_MODIFIED',
118
+ },
119
+ headers: {
120
+ 'X-RestLi-Method': 'FINDER',
121
+ },
122
+ });
123
+ return data.elements || [];
124
+ }
125
+
126
+ /**
127
+ * Get a specific post by URN.
128
+ * @param {string} postUrn
129
+ */
130
+ async getPost(postUrn) {
131
+ const encoded = encodeURIComponent(postUrn);
132
+ const { data } = await this.client.get(`/rest/posts/${encoded}`);
133
+ return data;
134
+ }
135
+
136
+ /**
137
+ * Delete a post.
138
+ * @param {string} postUrn
139
+ */
140
+ async deletePost(postUrn) {
141
+ const encoded = encodeURIComponent(postUrn);
142
+ await this.client.delete(`/rest/posts/${encoded}`);
143
+ return { success: true };
144
+ }
145
+
146
+ /**
147
+ * Add a comment to a post.
148
+ * @param {string} postUrn
149
+ * @param {string} text
150
+ * @param {string} actorUrn
151
+ */
152
+ async addComment(postUrn, text, actorUrn) {
153
+ const encoded = encodeURIComponent(postUrn);
154
+ const { data } = await this.client.post(`/rest/socialActions/${encoded}/comments`, {
155
+ actor: actorUrn,
156
+ message: { text },
157
+ });
158
+ return data;
159
+ }
160
+
161
+ /**
162
+ * Get comments on a post.
163
+ * Requires r_member_social permission (restricted — approved apps only).
164
+ * @param {string} postUrn
165
+ * @param {number} count
166
+ */
167
+ async getComments(postUrn, count = 10) {
168
+ const encoded = encodeURIComponent(postUrn);
169
+ const { data } = await this.client.get(`/rest/socialActions/${encoded}/comments`, {
170
+ params: { count },
171
+ headers: {
172
+ 'X-RestLi-Method': 'FINDER',
173
+ },
174
+ });
175
+ return data.elements || [];
176
+ }
177
+
178
+ /**
179
+ * Like a post.
180
+ * @param {string} postUrn
181
+ * @param {string} actorUrn
182
+ */
183
+ async likePost(postUrn, actorUrn) {
184
+ const encoded = encodeURIComponent(postUrn);
185
+ const { data } = await this.client.post(`/rest/socialActions/${encoded}/likes`, {
186
+ actor: actorUrn,
187
+ });
188
+ return data;
189
+ }
190
+ }
@@ -67,7 +67,10 @@ export class STTService {
67
67
  const result = await this._transcribeElevenLabs(filePath);
68
68
  if (result) return result;
69
69
  } catch (err) {
70
- this.logger.warn(`[STT] ElevenLabs failed, trying fallback: ${err.message}`);
70
+ const detail = err.response
71
+ ? `API ${err.response.status}: ${JSON.stringify(err.response.data).slice(0, 200)}`
72
+ : err.message;
73
+ this.logger.warn(`[STT] ElevenLabs failed, trying fallback: ${detail}`);
71
74
  }
72
75
  }
73
76
 
@@ -76,7 +79,10 @@ export class STTService {
76
79
  try {
77
80
  return await this._transcribeWhisper(filePath);
78
81
  } catch (err) {
79
- this.logger.error(`[STT] Whisper fallback also failed: ${err.message}`);
82
+ const detail = err.response
83
+ ? `API ${err.response.status}: ${JSON.stringify(err.response.data).slice(0, 200)}`
84
+ : err.message;
85
+ this.logger.error(`[STT] Whisper fallback also failed: ${detail}`);
80
86
  }
81
87
  }
82
88
 
@@ -1,6 +1,6 @@
1
1
  import axios from 'axios';
2
2
  import { createHash } from 'crypto';
3
- import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, unlinkSync } from 'fs';
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, unlinkSync, statSync } from 'fs';
4
4
  import { join } from 'path';
5
5
  import { homedir } from 'os';
6
6
  import { getLogger } from '../utils/logger.js';
@@ -8,6 +8,7 @@ import { getLogger } from '../utils/logger.js';
8
8
  const CACHE_DIR = join(homedir(), '.kernelbot', 'tts-cache');
9
9
  const DEFAULT_VOICE_ID = 'JBFqnCBsd6RMkjVDRZzb'; // ElevenLabs "George" voice
10
10
  const MAX_TEXT_LENGTH = 5000; // ElevenLabs limit
11
+ const MAX_CACHE_FILES = 100; // Evict oldest entries when cache exceeds this count
11
12
 
12
13
  /**
13
14
  * Text-to-Speech service using ElevenLabs API.
@@ -90,6 +91,9 @@ export class TTSService {
90
91
  writeFileSync(cachedPath, audioBuffer);
91
92
  this.logger.info(`[TTS] Synthesized and cached: ${cachedPath} (${audioBuffer.length} bytes)`);
92
93
 
94
+ // Evict oldest cache entries if over the limit
95
+ this._evictCache();
96
+
93
97
  return cachedPath;
94
98
  } catch (err) {
95
99
  if (err.response) {
@@ -109,7 +113,33 @@ export class TTSService {
109
113
  return createHash('sha256').update(`${voiceId}:${text}`).digest('hex').slice(0, 16);
110
114
  }
111
115
 
112
- /** Clear the TTS cache. */
116
+ /** Evict oldest cache entries when the cache exceeds MAX_CACHE_FILES. */
117
+ _evictCache() {
118
+ try {
119
+ const files = readdirSync(CACHE_DIR);
120
+ if (files.length <= MAX_CACHE_FILES) return;
121
+
122
+ // Sort by modification time (oldest first)
123
+ const sorted = files
124
+ .map((f) => {
125
+ const fullPath = join(CACHE_DIR, f);
126
+ try { return { name: f, mtime: statSync(fullPath).mtimeMs }; }
127
+ catch { return null; }
128
+ })
129
+ .filter(Boolean)
130
+ .sort((a, b) => a.mtime - b.mtime);
131
+
132
+ const toRemove = sorted.length - MAX_CACHE_FILES;
133
+ for (let i = 0; i < toRemove; i++) {
134
+ try { unlinkSync(join(CACHE_DIR, sorted[i].name)); } catch {}
135
+ }
136
+ this.logger.info(`[TTS] Cache eviction: removed ${toRemove} oldest file(s), ${MAX_CACHE_FILES} remain`);
137
+ } catch {
138
+ // Cache dir may not exist yet
139
+ }
140
+ }
141
+
142
+ /** Clear the entire TTS cache. */
113
143
  clearCache() {
114
144
  try {
115
145
  const files = readdirSync(CACHE_DIR);
@@ -0,0 +1,141 @@
1
+ import axios from 'axios';
2
+ import OAuth from 'oauth-1.0a';
3
+ import crypto from 'crypto';
4
+ import { getLogger } from '../utils/logger.js';
5
+
6
+ /**
7
+ * X (Twitter) API v2 client with OAuth 1.0a request signing.
8
+ */
9
+ export class XApi {
10
+ constructor({ consumerKey, consumerSecret, accessToken, accessTokenSecret }) {
11
+ this._userId = null;
12
+
13
+ this.oauth = OAuth({
14
+ consumer: { key: consumerKey, secret: consumerSecret },
15
+ signature_method: 'HMAC-SHA1',
16
+ hash_function(baseString, key) {
17
+ return crypto.createHmac('sha1', key).update(baseString).digest('base64');
18
+ },
19
+ });
20
+
21
+ this.token = { key: accessToken, secret: accessTokenSecret };
22
+
23
+ this.client = axios.create({
24
+ baseURL: 'https://api.twitter.com',
25
+ timeout: 30000,
26
+ });
27
+
28
+ // Sign every request with OAuth 1.0a
29
+ this.client.interceptors.request.use((config) => {
30
+ const url = `${config.baseURL}${config.url}`;
31
+ const authHeader = this.oauth.toHeader(
32
+ this.oauth.authorize({ url, method: config.method.toUpperCase() }, this.token),
33
+ );
34
+ config.headers = { ...config.headers, ...authHeader, 'Content-Type': 'application/json' };
35
+ return config;
36
+ });
37
+
38
+ // Retry with backoff on 429
39
+ this.client.interceptors.response.use(null, async (error) => {
40
+ const config = error.config;
41
+ if (error.response?.status === 429 && !config._retried) {
42
+ config._retried = true;
43
+ const retryAfter = parseInt(error.response.headers['retry-after'] || '5', 10);
44
+ getLogger().warn(`[X API] Rate limited, retrying after ${retryAfter}s`);
45
+ await new Promise((r) => setTimeout(r, retryAfter * 1000));
46
+ return this.client(config);
47
+ }
48
+ return Promise.reject(error);
49
+ });
50
+ }
51
+
52
+ /** Resolve and cache the authenticated user's ID. */
53
+ async _getUserId() {
54
+ if (this._userId) return this._userId;
55
+ const me = await this.getMe();
56
+ this._userId = me.id;
57
+ return this._userId;
58
+ }
59
+
60
+ /** GET /2/users/me */
61
+ async getMe() {
62
+ const { data } = await this.client.get('/2/users/me', {
63
+ params: { 'user.fields': 'id,name,username,description,public_metrics' },
64
+ });
65
+ if (data.data) {
66
+ this._userId = data.data.id;
67
+ }
68
+ return data.data;
69
+ }
70
+
71
+ /** POST /2/tweets — create a new tweet */
72
+ async postTweet(text) {
73
+ const { data } = await this.client.post('/2/tweets', { text });
74
+ return data.data;
75
+ }
76
+
77
+ /** POST /2/tweets — reply to an existing tweet */
78
+ async replyToTweet(text, replyToId) {
79
+ const { data } = await this.client.post('/2/tweets', {
80
+ text,
81
+ reply: { in_reply_to_tweet_id: replyToId },
82
+ });
83
+ return data.data;
84
+ }
85
+
86
+ /** GET /2/tweets/:id */
87
+ async getTweet(tweetId) {
88
+ const { data } = await this.client.get(`/2/tweets/${tweetId}`, {
89
+ params: { 'tweet.fields': 'id,text,author_id,created_at,public_metrics,conversation_id' },
90
+ });
91
+ return data.data;
92
+ }
93
+
94
+ /** GET /2/users/:id/tweets */
95
+ async getMyTweets(count = 10) {
96
+ const userId = await this._getUserId();
97
+ const { data } = await this.client.get(`/2/users/${userId}/tweets`, {
98
+ params: {
99
+ max_results: Math.min(Math.max(count, 5), 100),
100
+ 'tweet.fields': 'id,text,created_at,public_metrics',
101
+ },
102
+ });
103
+ return data.data || [];
104
+ }
105
+
106
+ /** GET /2/tweets/search/recent */
107
+ async searchRecentTweets(query, count = 10) {
108
+ const { data } = await this.client.get('/2/tweets/search/recent', {
109
+ params: {
110
+ query,
111
+ max_results: Math.min(Math.max(count, 10), 100),
112
+ 'tweet.fields': 'id,text,author_id,created_at,public_metrics',
113
+ },
114
+ });
115
+ return data.data || [];
116
+ }
117
+
118
+ /** DELETE /2/tweets/:id */
119
+ async deleteTweet(tweetId) {
120
+ const { data } = await this.client.delete(`/2/tweets/${tweetId}`);
121
+ return data.data;
122
+ }
123
+
124
+ /** POST /2/users/:id/likes */
125
+ async likeTweet(tweetId) {
126
+ const userId = await this._getUserId();
127
+ const { data } = await this.client.post(`/2/users/${userId}/likes`, {
128
+ tweet_id: tweetId,
129
+ });
130
+ return data.data;
131
+ }
132
+
133
+ /** POST /2/users/:id/retweets */
134
+ async retweet(tweetId) {
135
+ const userId = await this._getUserId();
136
+ const { data } = await this.client.post(`/2/users/${userId}/retweets`, {
137
+ tweet_id: tweetId,
138
+ });
139
+ return data.data;
140
+ }
141
+ }
@@ -40,6 +40,13 @@ export const WORKER_TYPES = {
40
40
  description: 'Deep web research and analysis',
41
41
  timeout: 600, // 10 minutes
42
42
  },
43
+ social: {
44
+ label: 'Social Worker',
45
+ emoji: '📱',
46
+ categories: ['linkedin', 'x'],
47
+ description: 'LinkedIn and X (Twitter) posting, engagement, feed reading',
48
+ timeout: 120, // 2 minutes
49
+ },
43
50
  };
44
51
 
45
52
  /**
@@ -13,6 +13,8 @@ export const TOOL_CATEGORIES = {
13
13
  network: ['check_port', 'curl_url', 'nginx_reload'],
14
14
  browser: ['web_search', 'browse_website', 'screenshot_website', 'extract_content', 'send_image', 'interact_with_page'],
15
15
  jira: ['jira_get_ticket', 'jira_search_tickets', 'jira_list_my_tickets', 'jira_get_project_tickets'],
16
+ linkedin: ['linkedin_create_post', 'linkedin_get_my_posts', 'linkedin_get_post', 'linkedin_comment_on_post', 'linkedin_get_comments', 'linkedin_like_post', 'linkedin_get_profile', 'linkedin_delete_post'],
17
+ x: ['x_post_tweet', 'x_reply_to_tweet', 'x_get_my_tweets', 'x_get_tweet', 'x_search_tweets', 'x_like_tweet', 'x_retweet', 'x_delete_tweet', 'x_get_profile'],
16
18
  };
17
19
 
18
20
  const CATEGORY_KEYWORDS = {
@@ -25,6 +27,8 @@ const CATEGORY_KEYWORDS = {
25
27
  network: ['port', 'curl', 'http', 'nginx', 'network', 'api', 'endpoint', 'request', 'url', 'fetch'],
26
28
  browser: ['search', 'find', 'look up', 'browse', 'screenshot', 'scrape', 'website', 'web page', 'webpage', 'extract content', 'html', 'css selector'],
27
29
  jira: ['jira', 'ticket', 'issue', 'sprint', 'backlog', 'story', 'epic'],
30
+ linkedin: ['linkedin', 'post on linkedin', 'linkedin post', 'linkedin comment', 'share on linkedin'],
31
+ x: ['twitter', 'tweet', 'x post', 'x.com', 'retweet', 'post on x', 'post on twitter'],
28
32
  };
29
33
 
30
34
  // Categories that imply other categories