@orchagent/cli 0.3.67 → 0.3.68

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.
@@ -8,6 +8,7 @@ const promises_1 = __importDefault(require("fs/promises"));
8
8
  const path_1 = __importDefault(require("path"));
9
9
  const errors_1 = require("../lib/errors");
10
10
  const github_weekly_summary_1 = require("./templates/github-weekly-summary");
11
+ const support_agent_1 = require("./templates/support-agent");
11
12
  const MANIFEST_TEMPLATE = `{
12
13
  "name": "my-agent",
13
14
  "description": "A simple AI agent",
@@ -87,6 +88,51 @@ if __name__ == "__main__":
87
88
  main()
88
89
  `;
89
90
  function readmeTemplate(agentName, flavor) {
91
+ if (flavor === 'support_agent') {
92
+ return `# ${agentName}
93
+
94
+ A multi-platform support agent powered by Claude. Connects to Discord, Telegram, and/or Slack.
95
+
96
+ ## Setup
97
+
98
+ ### 1. Customize
99
+
100
+ Edit \`config.py\` — set your product name, description, and bot name.
101
+
102
+ ### 2. Add Knowledge
103
+
104
+ Replace the example files in \`knowledge/\` with your own docs.
105
+ Files named \`NN-topic-name.md\` auto-discover as topics.
106
+
107
+ ### 3. Set Platform Tokens
108
+
109
+ Copy \`.env.example\` to \`.env\` and fill in tokens for the platforms you want.
110
+ At least one platform token is required.
111
+
112
+ ### 4. Run Locally
113
+
114
+ \`\`\`sh
115
+ pip install -r requirements.txt
116
+ python main.py
117
+ \`\`\`
118
+
119
+ ### 5. Deploy
120
+
121
+ \`\`\`sh
122
+ orch publish
123
+ # Add secrets in your workspace (web dashboard > Settings > Secrets)
124
+ orch service deploy
125
+ \`\`\`
126
+
127
+ ## Platforms
128
+
129
+ | Platform | Token Required | Extra Config |
130
+ |----------|---------------|--------------|
131
+ | Discord | \`DISCORD_BOT_TOKEN\` | \`DISCORD_CHANNEL_IDS\` |
132
+ | Telegram | \`TELEGRAM_BOT_TOKEN\` | — |
133
+ | Slack | \`SLACK_BOT_TOKEN\` | \`SLACK_APP_TOKEN\` |
134
+ `;
135
+ }
90
136
  if (flavor === 'discord') {
91
137
  return `# ${agentName}
92
138
 
@@ -479,7 +525,7 @@ function registerInitCommand(program) {
479
525
  .option('--type <type>', 'Type: prompt, tool, agent, or skill (legacy aliases: agentic, code)', 'prompt')
480
526
  .option('--orchestrator', 'Create an orchestrator agent with dependency scaffolding and SDK boilerplate')
481
527
  .option('--run-mode <mode>', 'Run mode for agents: on_demand or always_on', 'on_demand')
482
- .option('--template <name>', 'Start from a template (available: github-weekly-summary, discord)')
528
+ .option('--template <name>', 'Start from a template (available: support-agent, discord, github-weekly-summary)')
483
529
  .action(async (name, options) => {
484
530
  const cwd = process.cwd();
485
531
  let runMode = (options.runMode || 'on_demand').trim().toLowerCase();
@@ -495,7 +541,7 @@ function registerInitCommand(program) {
495
541
  }
496
542
  if (options.template) {
497
543
  const template = options.template.trim().toLowerCase();
498
- const validTemplates = ['discord', 'github-weekly-summary'];
544
+ const validTemplates = ['support-agent', 'discord', 'github-weekly-summary'];
499
545
  if (!validTemplates.includes(template)) {
500
546
  throw new errors_1.CliError(`Unknown --template '${template}'. Available templates: ${validTemplates.join(', ')}`);
501
547
  }
@@ -505,7 +551,11 @@ function registerInitCommand(program) {
505
551
  if (initMode.type === 'skill') {
506
552
  throw new errors_1.CliError('Cannot use --template with --type skill.');
507
553
  }
508
- if (template === 'discord') {
554
+ if (template === 'support-agent') {
555
+ initMode = { type: 'agent', flavor: 'support_agent' };
556
+ runMode = 'always_on';
557
+ }
558
+ else if (template === 'discord') {
509
559
  initMode = { type: 'agent', flavor: 'discord' };
510
560
  runMode = 'always_on';
511
561
  }
@@ -550,6 +600,80 @@ function registerInitCommand(program) {
550
600
  }
551
601
  return;
552
602
  }
603
+ // Handle support-agent template separately (multi-file structure)
604
+ if (initMode.flavor === 'support_agent') {
605
+ const manifestPath = path_1.default.join(targetDir, 'orchagent.json');
606
+ try {
607
+ await promises_1.default.access(manifestPath);
608
+ throw new errors_1.CliError(`Already initialized (orchagent.json exists in ${name ? name + '/' : 'current directory'})`);
609
+ }
610
+ catch (err) {
611
+ if (err.code !== 'ENOENT') {
612
+ throw err;
613
+ }
614
+ }
615
+ // Create subdirectories
616
+ await promises_1.default.mkdir(path_1.default.join(targetDir, 'connectors'), { recursive: true });
617
+ await promises_1.default.mkdir(path_1.default.join(targetDir, 'knowledge'), { recursive: true });
618
+ // Write manifest
619
+ const manifest = {
620
+ name: agentName,
621
+ type: 'agent',
622
+ description: 'Multi-platform support agent powered by Claude. Connects to Discord, Telegram, and/or Slack.',
623
+ run_mode: 'always_on',
624
+ runtime: { command: 'python main.py' },
625
+ entrypoint: 'main.py',
626
+ supported_providers: ['anthropic'],
627
+ default_models: { anthropic: 'claude-sonnet-4-5-20250929' },
628
+ required_secrets: [],
629
+ optional_secrets: ['DISCORD_BOT_TOKEN', 'DISCORD_CHANNEL_IDS', 'TELEGRAM_BOT_TOKEN', 'SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN'],
630
+ tags: ['support', 'discord', 'telegram', 'slack', 'always-on', 'multi-platform'],
631
+ bundle: {
632
+ include: ['*.py', 'connectors/*.py', 'knowledge/*.md', 'requirements.txt'],
633
+ exclude: ['tests/', '__pycache__', '*.pyc', '.pytest_cache'],
634
+ },
635
+ };
636
+ await promises_1.default.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
637
+ // Write all source files
638
+ await promises_1.default.writeFile(path_1.default.join(targetDir, 'config.py'), support_agent_1.SA_CONFIG_PY);
639
+ await promises_1.default.writeFile(path_1.default.join(targetDir, 'brain.py'), support_agent_1.SA_BRAIN_PY);
640
+ await promises_1.default.writeFile(path_1.default.join(targetDir, 'main.py'), support_agent_1.SA_MAIN_PY);
641
+ await promises_1.default.writeFile(path_1.default.join(targetDir, 'connectors', '__init__.py'), '');
642
+ await promises_1.default.writeFile(path_1.default.join(targetDir, 'connectors', 'discord_connector.py'), support_agent_1.SA_DISCORD_CONNECTOR_PY);
643
+ await promises_1.default.writeFile(path_1.default.join(targetDir, 'connectors', 'telegram_connector.py'), support_agent_1.SA_TELEGRAM_CONNECTOR_PY);
644
+ await promises_1.default.writeFile(path_1.default.join(targetDir, 'connectors', 'slack_connector.py'), support_agent_1.SA_SLACK_CONNECTOR_PY);
645
+ await promises_1.default.writeFile(path_1.default.join(targetDir, 'requirements.txt'), support_agent_1.SA_REQUIREMENTS);
646
+ await promises_1.default.writeFile(path_1.default.join(targetDir, 'knowledge', '00-overview.md'), support_agent_1.SA_OVERVIEW_MD);
647
+ await promises_1.default.writeFile(path_1.default.join(targetDir, 'knowledge', '99-faq.md'), support_agent_1.SA_FAQ_MD);
648
+ await promises_1.default.writeFile(path_1.default.join(targetDir, '.env.example'), support_agent_1.SA_ENV_EXAMPLE);
649
+ await promises_1.default.writeFile(path_1.default.join(targetDir, 'README.md'), readmeTemplate(agentName, 'support_agent'));
650
+ const prefix = name ? name + '/' : '';
651
+ process.stdout.write(`\nInitialized support-agent "${agentName}" in ${targetDir}\n`);
652
+ process.stdout.write(`\nFiles created:\n`);
653
+ process.stdout.write(` ${prefix}orchagent.json Agent manifest\n`);
654
+ process.stdout.write(` ${prefix}config.py Customize here (4 fields)\n`);
655
+ process.stdout.write(` ${prefix}brain.py Three-tier classifier + responder\n`);
656
+ process.stdout.write(` ${prefix}main.py Startup orchestrator\n`);
657
+ process.stdout.write(` ${prefix}connectors/discord_connector.py Discord connector\n`);
658
+ process.stdout.write(` ${prefix}connectors/telegram_connector.py Telegram connector\n`);
659
+ process.stdout.write(` ${prefix}connectors/slack_connector.py Slack connector\n`);
660
+ process.stdout.write(` ${prefix}knowledge/00-overview.md Example FAQ knowledge\n`);
661
+ process.stdout.write(` ${prefix}knowledge/99-faq.md Example FAQ knowledge\n`);
662
+ process.stdout.write(` ${prefix}requirements.txt Python dependencies\n`);
663
+ process.stdout.write(` ${prefix}.env.example Environment variables\n`);
664
+ process.stdout.write(` ${prefix}README.md Setup guide\n`);
665
+ process.stdout.write(`\nNext steps:\n`);
666
+ const s = name ? 2 : 1;
667
+ if (name) {
668
+ process.stdout.write(` 1. cd ${name}\n`);
669
+ }
670
+ process.stdout.write(` ${s}. Edit config.py with your product name and description\n`);
671
+ process.stdout.write(` ${s + 1}. Replace knowledge/ files with your own docs\n`);
672
+ process.stdout.write(` ${s + 2}. Copy .env.example to .env and add platform tokens\n`);
673
+ process.stdout.write(` ${s + 3}. Test locally: pip install -r requirements.txt && python main.py\n`);
674
+ process.stdout.write(` ${s + 4}. Deploy: orch publish && orch service deploy\n`);
675
+ return;
676
+ }
553
677
  // Handle github-weekly-summary template separately (own file set + output)
554
678
  if (initMode.flavor === 'github_weekly_summary') {
555
679
  const manifestPath = path_1.default.join(targetDir, 'orchagent.json');
@@ -901,7 +901,7 @@ function registerPublishCommand(program) {
901
901
  }
902
902
  // Upload the bundle with entrypoint
903
903
  process.stdout.write(` Uploading bundle...\n`);
904
- const uploadResult = await (0, api_1.uploadCodeBundle)(config, agentId, bundlePath, bundleEntrypoint);
904
+ const uploadResult = await (0, api_1.uploadCodeBundle)(config, agentId, bundlePath, bundleEntrypoint, workspaceId);
905
905
  process.stdout.write(` Uploaded: ${uploadResult.code_hash.substring(0, 12)}...\n`);
906
906
  // Show environment info if applicable
907
907
  if (uploadResult.environment_id) {
@@ -93,9 +93,10 @@ function registerServiceCommand(program) {
93
93
  .option('--command <cmd>', 'Override entrypoint command')
94
94
  .option('--arg <value>', 'Command argument (repeatable)', collectArray, [])
95
95
  .option('--pin', 'Pin to deployed version (disable auto-update on publish)')
96
+ .option('--profile <name>', 'Use API key from named profile')
96
97
  .option('--json', 'Output as JSON')
97
98
  .action(async (agentArg, options) => {
98
- const config = await (0, config_1.getResolvedConfig)();
99
+ const config = await (0, config_1.getResolvedConfig)({}, options.profile);
99
100
  if (!config.apiKey) {
100
101
  throw new errors_1.CliError('Missing API key. Run `orch login` first.');
101
102
  }
@@ -0,0 +1,570 @@
1
+ "use strict";
2
+ /**
3
+ * Multi-Platform Support Agent template.
4
+ *
5
+ * Generates a three-tier support agent that connects to Discord, Telegram,
6
+ * and/or Slack based on which tokens are configured.
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.SA_ENV_EXAMPLE = exports.SA_FAQ_MD = exports.SA_OVERVIEW_MD = exports.SA_REQUIREMENTS = exports.SA_MAIN_PY = exports.SA_SLACK_CONNECTOR_PY = exports.SA_TELEGRAM_CONNECTOR_PY = exports.SA_DISCORD_CONNECTOR_PY = exports.SA_BRAIN_PY = exports.SA_CONFIG_PY = void 0;
10
+ exports.SA_CONFIG_PY = `"""
11
+ Support Agent Configuration — Edit these 4 fields to customize your agent.
12
+ """
13
+
14
+ # The name your bot uses when responding
15
+ BOT_NAME = "Support Agent"
16
+
17
+ # Your product/service name — used in prompts and classification
18
+ PRODUCT_NAME = "My Product"
19
+
20
+ # One-sentence description — helps the classifier understand your domain
21
+ PRODUCT_DESCRIPTION = "a platform for doing amazing things"
22
+
23
+ # Knowledge files used for the FAQ tier (general questions).
24
+ FAQ_FILES = ["00-overview.md", "99-faq.md"]
25
+ `;
26
+ exports.SA_BRAIN_PY = `"""
27
+ Support Agent Brain — Three-Tier Architecture
28
+
29
+ Tier 1: CLASSIFY — Haiku classifier decides if/how to respond
30
+ Tier 2: FAQ — General knowledge handles ~80% of questions (cached)
31
+ Tier 3: DEEP DOCS — Topic-specific docs for detailed questions (cached per topic)
32
+ """
33
+
34
+ import json
35
+ import logging
36
+ import os
37
+ import sys
38
+ from pathlib import Path
39
+
40
+ import anthropic
41
+
42
+ from config import BOT_NAME, FAQ_FILES, PRODUCT_DESCRIPTION, PRODUCT_NAME
43
+
44
+ logger = logging.getLogger("support-agent.brain")
45
+
46
+ CLASSIFY_MODEL = os.environ.get("CLASSIFY_MODEL", "claude-haiku-4-5-20251001")
47
+ RESPOND_MODEL = os.environ.get("RESPOND_MODEL", "claude-sonnet-4-5-20250929")
48
+ CLASSIFY_MAX_TOKENS = 150
49
+ RESPOND_MAX_TOKENS = int(os.environ.get("MAX_TOKENS", "1024"))
50
+
51
+ DOCS_DIR = Path(__file__).parent / "knowledge"
52
+
53
+
54
+ def load_doc_files() -> dict[str, str]:
55
+ docs: dict[str, str] = {}
56
+ if not DOCS_DIR.is_dir():
57
+ logger.warning("knowledge/ directory not found")
58
+ return docs
59
+ for md_file in sorted(DOCS_DIR.glob("*.md")):
60
+ content = md_file.read_text(encoding="utf-8").strip()
61
+ if content:
62
+ docs[md_file.name] = content
63
+ logger.info("Loaded %d knowledge files", len(docs))
64
+ return docs
65
+
66
+
67
+ def discover_topics(doc_files: dict[str, str]) -> dict[str, list[str]]:
68
+ topics: dict[str, list[str]] = {}
69
+ faq_set = set(FAQ_FILES)
70
+ for filename in sorted(doc_files.keys()):
71
+ if filename in faq_set:
72
+ continue
73
+ stem = filename.rsplit(".", 1)[0]
74
+ parts = stem.split("-", 1)
75
+ if len(parts) == 2 and parts[0].isdigit():
76
+ topic = parts[1]
77
+ else:
78
+ topic = stem
79
+ topic = topic.strip().lower().replace(" ", "-").replace("_", "-")
80
+ if not topic:
81
+ continue
82
+ topics.setdefault(topic, []).append(filename)
83
+ return topics
84
+
85
+
86
+ def build_docs_context(doc_files: dict[str, str], filenames: list[str]) -> str:
87
+ parts: list[str] = []
88
+ for name in filenames:
89
+ if name in doc_files:
90
+ title = name.rsplit(".", 1)[0].replace("-", " ").title()
91
+ parts.append(f"## {title}\\n\\n{doc_files[name]}")
92
+ return "\\n\\n---\\n\\n".join(parts)
93
+
94
+
95
+ def get_deep_files(topics: list[str], topic_files: dict[str, list[str]]) -> list[str]:
96
+ files: list[str] = []
97
+ seen: set[str] = set()
98
+ for topic in topics:
99
+ for f in topic_files.get(topic, []):
100
+ if f not in seen:
101
+ files.append(f)
102
+ seen.add(f)
103
+ for f in FAQ_FILES:
104
+ if f not in seen:
105
+ files.append(f)
106
+ seen.add(f)
107
+ return files
108
+
109
+
110
+ def build_response_rules() -> str:
111
+ return f"""\\
112
+ You are {BOT_NAME}, the official support assistant for {PRODUCT_NAME}.
113
+
114
+ Your job is to answer user questions about {PRODUCT_NAME} accurately and helpfully, grounded in the documentation provided below. Follow these rules:
115
+
116
+ 1. Base your answers ONLY on the documentation below. Do not guess or make up features.
117
+ 2. Be concise and direct. Messages should be readable, not walls of text.
118
+ 3. Use code blocks for commands and code examples.
119
+ 4. If the documentation doesn't cover the question, say: "I'm not sure about this — a human will follow up." Do NOT hallucinate an answer.
120
+ 5. If a question is ambiguous, ask for clarification before answering.
121
+ 6. Be friendly and professional. You represent the {PRODUCT_NAME} team.
122
+ 7. Do not discuss pricing specifics beyond what's in the docs.
123
+ 8. Do not share internal implementation details, architecture decisions, or roadmap items.
124
+ 9. Keep responses under 1800 characters to stay readable.
125
+
126
+ ---
127
+
128
+ # {PRODUCT_NAME} Documentation
129
+
130
+ """
131
+
132
+
133
+ def build_classify_system_prompt(topic_list: list[str]) -> str:
134
+ topic_csv = ", ".join(topic_list) if topic_list else "general"
135
+ return f"""\\
136
+ You are a message classifier for the {PRODUCT_NAME} support channel.
137
+ {PRODUCT_NAME} is {PRODUCT_DESCRIPTION}.
138
+
139
+ Classify the user's message. Return ONLY valid JSON, no other text.
140
+
141
+ Topics: {topic_csv}
142
+
143
+ {{"respond": bool, "tier": "faq" or "deep", "topics": ["topic1"]}}
144
+
145
+ Rules:
146
+ - "respond": false for greetings, thanks, off-topic chat, non-questions, emoji, memes
147
+ - "tier": "faq" for simple/general questions about the platform
148
+ - "tier": "deep" for questions needing specific technical details
149
+ - "topics": 1-2 relevant topics when tier is "deep", empty list for "faq" """
150
+
151
+
152
+ def build_faq_prompt(doc_files: dict[str, str]) -> str:
153
+ return build_response_rules() + build_docs_context(doc_files, FAQ_FILES)
154
+
155
+
156
+ def build_deep_prompt(doc_files: dict[str, str], topics: list[str], topic_files: dict[str, list[str]]) -> str:
157
+ files = get_deep_files(topics, topic_files)
158
+ return build_response_rules() + build_docs_context(doc_files, files)
159
+
160
+
161
+ def create_anthropic_client() -> anthropic.Anthropic:
162
+ api_key = os.environ.get("ANTHROPIC_API_KEY", "")
163
+ if not api_key:
164
+ logger.error("ANTHROPIC_API_KEY not set")
165
+ sys.exit(1)
166
+ return anthropic.Anthropic(api_key=api_key)
167
+
168
+
169
+ def classify_message(client: anthropic.Anthropic, user_message: str, system_prompt: str) -> dict:
170
+ try:
171
+ response = client.messages.create(
172
+ model=CLASSIFY_MODEL, max_tokens=CLASSIFY_MAX_TOKENS,
173
+ system=system_prompt, messages=[{"role": "user", "content": user_message}],
174
+ )
175
+ raw = response.content[0].text.strip()
176
+ if raw.startswith("\\\`\\\`\\\`"):
177
+ raw = raw.split("\\n", 1)[1].rsplit("\\\`\\\`\\\`", 1)[0].strip()
178
+ result = json.loads(raw)
179
+ logger.info("Classify: respond=%s tier=%s topics=%s (in=%d out=%d)",
180
+ result.get("respond"), result.get("tier"), result.get("topics"),
181
+ response.usage.input_tokens, response.usage.output_tokens)
182
+ return result
183
+ except (json.JSONDecodeError, KeyError, IndexError) as exc:
184
+ logger.warning("Classifier returned invalid JSON: %s", exc)
185
+ return {"respond": True, "tier": "faq", "topics": []}
186
+ except anthropic.APIError as exc:
187
+ logger.warning("Classifier API error: %s", exc)
188
+ return {"respond": True, "tier": "faq", "topics": []}
189
+
190
+
191
+ def query_llm(client: anthropic.Anthropic, system_prompt: str, user_message: str) -> tuple[str, int, int]:
192
+ response = client.messages.create(
193
+ model=RESPOND_MODEL, max_tokens=RESPOND_MAX_TOKENS,
194
+ system=[{"type": "text", "text": system_prompt, "cache_control": {"type": "ephemeral"}}],
195
+ messages=[{"role": "user", "content": user_message}],
196
+ )
197
+ return response.content[0].text, response.usage.input_tokens, response.usage.output_tokens
198
+
199
+
200
+ class Brain:
201
+ def __init__(self, anthropic_client, doc_files, topic_files, faq_prompt, classify_system_prompt):
202
+ self.client = anthropic_client
203
+ self.doc_files = doc_files
204
+ self.topic_files = topic_files
205
+ self.faq_prompt = faq_prompt
206
+ self.classify_system_prompt = classify_system_prompt
207
+
208
+ def classify(self, message: str) -> dict:
209
+ return classify_message(self.client, message, self.classify_system_prompt)
210
+
211
+ def respond_faq(self, message: str) -> tuple[str, int, int]:
212
+ return query_llm(self.client, self.faq_prompt, message)
213
+
214
+ def respond_deep(self, message: str, topics: list[str]) -> tuple[str, int, int]:
215
+ prompt = build_deep_prompt(self.doc_files, topics, self.topic_files)
216
+ return query_llm(self.client, prompt, message)
217
+ `;
218
+ exports.SA_DISCORD_CONNECTOR_PY = `"""Discord connector for the support agent."""
219
+
220
+ import asyncio
221
+ import logging
222
+ import os
223
+ import time
224
+
225
+ import anthropic
226
+ import discord
227
+
228
+ from brain import Brain
229
+
230
+ logger = logging.getLogger("support-agent.discord")
231
+ USER_COOLDOWN_SECONDS = int(os.environ.get("USER_COOLDOWN_SECONDS", "5"))
232
+
233
+
234
+ def parse_channel_ids(raw: str) -> set[int]:
235
+ ids: set[int] = set()
236
+ for part in raw.split(","):
237
+ part = part.strip()
238
+ if part.isdigit():
239
+ ids.add(int(part))
240
+ return ids
241
+
242
+
243
+ def should_respond(message: discord.Message, allowed_channels: set[int]) -> bool:
244
+ if message.author.bot:
245
+ return False
246
+ channel_id = message.channel.id
247
+ parent_id = getattr(message.channel, "parent_id", None)
248
+ if channel_id not in allowed_channels and parent_id not in allowed_channels:
249
+ return False
250
+ if not message.content or not message.content.strip():
251
+ return False
252
+ if len(message.content.strip()) < 5:
253
+ return False
254
+ return True
255
+
256
+
257
+ class DiscordConnector(discord.Client):
258
+ def __init__(self, brain: Brain, allowed_channels: set[int]):
259
+ intents = discord.Intents.default()
260
+ intents.message_content = True
261
+ super().__init__(intents=intents)
262
+ self.brain = brain
263
+ self.allowed_channels = allowed_channels
264
+ self._user_last_response: dict[int, float] = {}
265
+
266
+ async def on_ready(self) -> None:
267
+ logger.info("Discord connected as %s (id=%s)", self.user, self.user.id)
268
+
269
+ async def on_message(self, message: discord.Message) -> None:
270
+ if not should_respond(message, self.allowed_channels):
271
+ return
272
+ now = time.time()
273
+ if now - self._user_last_response.get(message.author.id, 0) < USER_COOLDOWN_SECONDS:
274
+ return
275
+ user_text = message.content.strip()
276
+ classification = await asyncio.to_thread(self.brain.classify, user_text)
277
+ if not classification.get("respond", True):
278
+ return
279
+ tier = classification.get("tier", "faq")
280
+ topics = classification.get("topics", [])
281
+ try:
282
+ thread = await message.create_thread(name="Q: " + user_text[:80], auto_archive_duration=60)
283
+ except discord.HTTPException:
284
+ thread = message.channel
285
+ async with thread.typing():
286
+ try:
287
+ if tier == "deep" and topics:
288
+ answer, _, _ = await asyncio.to_thread(self.brain.respond_deep, user_text, topics)
289
+ else:
290
+ answer, _, _ = await asyncio.to_thread(self.brain.respond_faq, user_text)
291
+ except anthropic.RateLimitError:
292
+ await thread.send("I'm getting a lot of questions right now. Please try again in a minute.")
293
+ return
294
+ except anthropic.APITimeoutError:
295
+ await thread.send("My response timed out. Please try again.")
296
+ return
297
+ except anthropic.APIError:
298
+ await thread.send("I ran into an issue generating a response. A human will follow up.")
299
+ return
300
+ if len(answer) > 1900:
301
+ answer = answer[:1897] + "..."
302
+ await thread.send(answer)
303
+ self._user_last_response[message.author.id] = time.time()
304
+
305
+ async def run_async(self, token: str) -> None:
306
+ await self.start(token)
307
+ `;
308
+ exports.SA_TELEGRAM_CONNECTOR_PY = `"""Telegram connector for the support agent."""
309
+
310
+ import asyncio
311
+ import logging
312
+ import os
313
+ import time
314
+
315
+ from telegram import Update
316
+ from telegram.ext import Application, ContextTypes, MessageHandler, filters
317
+
318
+ from brain import Brain
319
+
320
+ logger = logging.getLogger("support-agent.telegram")
321
+ USER_COOLDOWN_SECONDS = int(os.environ.get("USER_COOLDOWN_SECONDS", "5"))
322
+
323
+
324
+ class TelegramConnector:
325
+ def __init__(self, brain: Brain, token: str):
326
+ self.brain = brain
327
+ self.token = token
328
+ self._user_last_response: dict[int, float] = {}
329
+
330
+ async def _handle_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
331
+ message = update.message
332
+ if not message or not message.text:
333
+ return
334
+ user_text = message.text.strip()
335
+ if len(user_text) < 5:
336
+ return
337
+ user_id = message.from_user.id if message.from_user else 0
338
+ now = time.time()
339
+ if now - self._user_last_response.get(user_id, 0) < USER_COOLDOWN_SECONDS:
340
+ return
341
+ classification = await asyncio.to_thread(self.brain.classify, user_text)
342
+ if not classification.get("respond", True):
343
+ return
344
+ tier = classification.get("tier", "faq")
345
+ topics = classification.get("topics", [])
346
+ await message.chat.send_action("typing")
347
+ try:
348
+ if tier == "deep" and topics:
349
+ answer, _, _ = await asyncio.to_thread(self.brain.respond_deep, user_text, topics)
350
+ else:
351
+ answer, _, _ = await asyncio.to_thread(self.brain.respond_faq, user_text)
352
+ except Exception:
353
+ await message.reply_text("I ran into an issue generating a response. A human will follow up.")
354
+ return
355
+ if len(answer) > 4000:
356
+ answer = answer[:3997] + "..."
357
+ await message.reply_text(answer)
358
+ self._user_last_response[user_id] = time.time()
359
+
360
+ async def run(self) -> None:
361
+ app = Application.builder().token(self.token).build()
362
+ app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, self._handle_message))
363
+ await app.initialize()
364
+ await app.start()
365
+ await app.updater.start_polling(drop_pending_updates=True)
366
+ try:
367
+ await asyncio.Event().wait()
368
+ finally:
369
+ await app.updater.stop()
370
+ await app.stop()
371
+ await app.shutdown()
372
+ `;
373
+ exports.SA_SLACK_CONNECTOR_PY = `"""Slack connector for the support agent (Socket Mode)."""
374
+
375
+ import asyncio
376
+ import logging
377
+ import os
378
+ import time
379
+
380
+ from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler
381
+ from slack_bolt.async_app import AsyncApp
382
+
383
+ from brain import Brain
384
+
385
+ logger = logging.getLogger("support-agent.slack")
386
+ USER_COOLDOWN_SECONDS = int(os.environ.get("USER_COOLDOWN_SECONDS", "5"))
387
+
388
+
389
+ class SlackConnector:
390
+ def __init__(self, brain: Brain, bot_token: str, app_token: str):
391
+ self.brain = brain
392
+ self.app = AsyncApp(token=bot_token)
393
+ self.app_token = app_token
394
+ self._user_last_response: dict[str, float] = {}
395
+ self._register_handlers()
396
+
397
+ def _register_handlers(self) -> None:
398
+ @self.app.event("message")
399
+ async def handle_message(event, say):
400
+ if event.get("bot_id") or event.get("subtype"):
401
+ return
402
+ text = event.get("text", "").strip()
403
+ if not text or len(text) < 5:
404
+ return
405
+ user_id = event.get("user", "unknown")
406
+ now = time.time()
407
+ if now - self._user_last_response.get(user_id, 0) < USER_COOLDOWN_SECONDS:
408
+ return
409
+ classification = await asyncio.to_thread(self.brain.classify, text)
410
+ if not classification.get("respond", True):
411
+ return
412
+ tier = classification.get("tier", "faq")
413
+ topics = classification.get("topics", [])
414
+ try:
415
+ if tier == "deep" and topics:
416
+ answer, _, _ = await asyncio.to_thread(self.brain.respond_deep, text, topics)
417
+ else:
418
+ answer, _, _ = await asyncio.to_thread(self.brain.respond_faq, text)
419
+ except Exception:
420
+ await say("I ran into an issue generating a response. A human will follow up.")
421
+ return
422
+ if len(answer) > 3000:
423
+ answer = answer[:2997] + "..."
424
+ await say(answer, thread_ts=event.get("ts"))
425
+ self._user_last_response[user_id] = time.time()
426
+
427
+ async def run(self) -> None:
428
+ handler = AsyncSocketModeHandler(self.app, self.app_token)
429
+ await handler.start_async()
430
+ `;
431
+ exports.SA_MAIN_PY = `"""
432
+ Multi-Platform Support Agent — Startup Orchestrator
433
+
434
+ Checks which platform tokens are configured and starts those connectors.
435
+ """
436
+
437
+ import asyncio
438
+ import logging
439
+ import os
440
+ import sys
441
+
442
+ from brain import (Brain, build_classify_system_prompt, build_faq_prompt,
443
+ create_anthropic_client, discover_topics, load_doc_files)
444
+ from config import FAQ_FILES
445
+
446
+ LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO").upper()
447
+ logging.basicConfig(level=getattr(logging, LOG_LEVEL, logging.INFO),
448
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s", stream=sys.stdout)
449
+ logger = logging.getLogger("support-agent")
450
+
451
+
452
+ def validate_knowledge(doc_files: dict[str, str]) -> None:
453
+ if not doc_files:
454
+ logger.error("No knowledge files found in knowledge/ directory")
455
+ sys.exit(1)
456
+ for faq_file in FAQ_FILES:
457
+ if faq_file not in doc_files:
458
+ logger.error("FAQ file '%s' not found in knowledge/", faq_file)
459
+ sys.exit(1)
460
+
461
+
462
+ async def run_connectors(brain: Brain) -> None:
463
+ tasks = []
464
+ active = []
465
+
466
+ discord_token = os.environ.get("DISCORD_BOT_TOKEN", "")
467
+ if discord_token:
468
+ from connectors.discord_connector import DiscordConnector, parse_channel_ids
469
+ channels_raw = os.environ.get("DISCORD_CHANNEL_IDS", "")
470
+ if not channels_raw:
471
+ logger.error("DISCORD_BOT_TOKEN set but DISCORD_CHANNEL_IDS missing")
472
+ sys.exit(1)
473
+ channels = parse_channel_ids(channels_raw)
474
+ if not channels:
475
+ logger.error("No valid channel IDs in DISCORD_CHANNEL_IDS")
476
+ sys.exit(1)
477
+ tasks.append(DiscordConnector(brain, channels).run_async(discord_token))
478
+ active.append("Discord")
479
+
480
+ telegram_token = os.environ.get("TELEGRAM_BOT_TOKEN", "")
481
+ if telegram_token:
482
+ from connectors.telegram_connector import TelegramConnector
483
+ tasks.append(TelegramConnector(brain, telegram_token).run())
484
+ active.append("Telegram")
485
+
486
+ slack_bot_token = os.environ.get("SLACK_BOT_TOKEN", "")
487
+ if slack_bot_token:
488
+ from connectors.slack_connector import SlackConnector
489
+ slack_app_token = os.environ.get("SLACK_APP_TOKEN", "")
490
+ if not slack_app_token:
491
+ logger.error("SLACK_BOT_TOKEN set but SLACK_APP_TOKEN missing")
492
+ sys.exit(1)
493
+ tasks.append(SlackConnector(brain, slack_bot_token, slack_app_token).run())
494
+ active.append("Slack")
495
+
496
+ if not tasks:
497
+ logger.error("No platform tokens configured. Set at least one of: DISCORD_BOT_TOKEN, TELEGRAM_BOT_TOKEN, SLACK_BOT_TOKEN")
498
+ sys.exit(1)
499
+
500
+ logger.info("Active platforms: %s", ", ".join(active))
501
+ await asyncio.gather(*tasks)
502
+
503
+
504
+ def main() -> None:
505
+ if not os.environ.get("ANTHROPIC_API_KEY"):
506
+ logger.error("ANTHROPIC_API_KEY not set")
507
+ sys.exit(1)
508
+
509
+ doc_files = load_doc_files()
510
+ validate_knowledge(doc_files)
511
+
512
+ topic_files = discover_topics(doc_files)
513
+ topic_list = sorted(topic_files.keys())
514
+ faq_prompt = build_faq_prompt(doc_files)
515
+ classify_prompt = build_classify_system_prompt(topic_list)
516
+
517
+ logger.info("Topics discovered: %s", topic_list)
518
+
519
+ brain = Brain(
520
+ anthropic_client=create_anthropic_client(),
521
+ doc_files=doc_files, topic_files=topic_files,
522
+ faq_prompt=faq_prompt, classify_system_prompt=classify_prompt,
523
+ )
524
+
525
+ logger.info("Starting Multi-Platform Support Agent")
526
+ asyncio.run(run_connectors(brain))
527
+
528
+
529
+ if __name__ == "__main__":
530
+ main()
531
+ `;
532
+ exports.SA_REQUIREMENTS = `discord.py>=2.3.0,<3.0.0
533
+ python-telegram-bot>=21.0,<22.0.0
534
+ slack-bolt>=1.18.0,<2.0.0
535
+ anthropic>=0.40.0,<1.0.0
536
+ `;
537
+ exports.SA_OVERVIEW_MD = `# My Product Overview
538
+
539
+ Welcome to My Product! This is a placeholder overview document.
540
+
541
+ Replace this file with your own product overview and FAQ-tier knowledge.
542
+
543
+ ## Getting Started
544
+
545
+ 1. Sign up at myproduct.com
546
+ 2. Follow the quick start guide
547
+ 3. Start building
548
+ `;
549
+ exports.SA_FAQ_MD = `# FAQ
550
+
551
+ **Q: What is My Product?**
552
+ A: My Product is a platform for doing amazing things. Replace this with your actual FAQ.
553
+
554
+ **Q: How do I get help?**
555
+ A: Ask in the support channel — our support agent will respond!
556
+ `;
557
+ exports.SA_ENV_EXAMPLE = `# Required — auto-injected in production via supported_providers
558
+ ANTHROPIC_API_KEY=
559
+
560
+ # --- Discord (optional) ---
561
+ DISCORD_BOT_TOKEN=
562
+ DISCORD_CHANNEL_IDS=
563
+
564
+ # --- Telegram (optional) ---
565
+ TELEGRAM_BOT_TOKEN=
566
+
567
+ # --- Slack (optional) ---
568
+ SLACK_BOT_TOKEN=
569
+ SLACK_APP_TOKEN=
570
+ `;
package/dist/lib/api.js CHANGED
@@ -320,7 +320,7 @@ async function downloadCodeBundle(config, org, agent, version) {
320
320
  /**
321
321
  * Upload a code bundle for a hosted tool agent.
322
322
  */
323
- async function uploadCodeBundle(config, agentId, bundlePath, entrypoint) {
323
+ async function uploadCodeBundle(config, agentId, bundlePath, entrypoint, workspaceId) {
324
324
  if (!config.apiKey) {
325
325
  throw new ApiError('Missing API key. Run `orchagent login` first.', 401);
326
326
  }
@@ -338,6 +338,9 @@ async function uploadCodeBundle(config, agentId, bundlePath, entrypoint) {
338
338
  if (entrypoint) {
339
339
  headers['x-entrypoint'] = entrypoint;
340
340
  }
341
+ if (workspaceId) {
342
+ headers['X-Workspace-Id'] = workspaceId;
343
+ }
341
344
  const response = await safeFetch(`${config.apiUrl.replace(/\/$/, '')}/agents/${agentId}/upload`, {
342
345
  method: 'POST',
343
346
  headers,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orchagent/cli",
3
- "version": "0.3.67",
3
+ "version": "0.3.68",
4
4
  "description": "Command-line interface for orchagent — deploy and run AI agents for your team",
5
5
  "license": "MIT",
6
6
  "author": "orchagent <hello@orchagent.io>",