orchestrix-yuri 2.0.2 → 2.1.0

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/README.md CHANGED
@@ -8,7 +8,10 @@ User describes idea → Yuri drives: Create → Plan → Develop → Test → De
8
8
 
9
9
  ## How It Works
10
10
 
11
- Yuri is a [Claude Code skill](https://code.claude.com/docs/en/skills) that orchestrates specialized AI agents via tmux sessions:
11
+ Yuri is a [Claude Code skill](https://code.claude.com/docs/en/skills) + Channel Gateway. It can be used in two ways:
12
+
13
+ 1. **Terminal mode** — activate `/yuri` inside any Claude Code session
14
+ 2. **Telegram mode** — chat with Yuri via Telegram bot, backed by a persistent tmux Claude Code session
12
15
 
13
16
  | Phase | What Yuri Does | Agents Involved |
14
17
  |-------|---------------|-----------------|
@@ -18,33 +21,60 @@ Yuri is a [Claude Code skill](https://code.claude.com/docs/en/skills) that orche
18
21
  | **4. Test** | Runs smoke tests per epic, fixes bugs, regression tests | QA + Dev |
19
22
  | **5. Deploy** | Recommends and executes deployment strategy | — |
20
23
 
24
+ ## Prerequisites
25
+
26
+ - **Node.js** >= 18
27
+ - **[Claude Code](https://claude.com/claude-code)** CLI installed and logged in
28
+ - **[tmux](https://github.com/tmux/tmux)** installed (`brew install tmux` on macOS, `apt install tmux` on Linux)
29
+ - **Orchestrix License Key** (get one at [orchestrix-mcp.youlidao.ai](https://orchestrix-mcp.youlidao.ai))
30
+ - **Telegram Bot Token** (for Telegram mode — get from [@BotFather](https://t.me/BotFather))
31
+
21
32
  ## Installation
22
33
 
34
+ ### Method A: From npm (recommended)
35
+
23
36
  ```bash
24
- npx orchestrix-yuri install
37
+ # Install globally
38
+ npm install -g orchestrix-yuri
39
+
40
+ # Initialize skill + global memory
41
+ orchestrix-yuri install
42
+
43
+ # Start Telegram gateway
44
+ orchestrix-yuri serve --telegram-token "YOUR_BOT_TOKEN"
25
45
  ```
26
46
 
27
- This installs the Yuri skill globally to `~/.claude/skills/yuri/`.
47
+ ### Method B: From source
48
+
49
+ ```bash
50
+ git clone https://github.com/anthropics/orchestrix-yuri.git
51
+ cd orchestrix-yuri
52
+ npm install
53
+
54
+ # Initialize skill + global memory
55
+ node bin/install.js install
56
+
57
+ # Start Telegram gateway
58
+ node bin/serve.js --telegram-token "YOUR_BOT_TOKEN"
59
+ ```
28
60
 
29
- ### Prerequisites
61
+ ### What `install` does
30
62
 
31
- - [Claude Code](https://claude.com/claude-code) CLI installed
32
- - [tmux](https://github.com/tmux/tmux) installed (`brew install tmux`)
33
- - An Orchestrix License Key (get one at [orchestrix-mcp.youlidao.ai](https://orchestrix-mcp.youlidao.ai))
63
+ - Copies the Yuri skill to `~/.claude/skills/yuri/`
64
+ - Initializes global memory at `~/.yuri/` (identity, boss profile, portfolio registry, focus, wisdom)
65
+ - Creates channel config at `~/.yuri/config/channels.yaml`
34
66
 
35
67
  ## Usage
36
68
 
69
+ ### Terminal Mode
70
+
37
71
  In any Claude Code session:
38
72
 
39
73
  ```
40
74
  /yuri
41
75
  ```
42
76
 
43
- Then either:
44
- - Describe your project idea in natural language
45
- - Use a specific command: `*create`, `*plan`, `*develop`, `*test`, `*deploy`
46
-
47
- ### Commands
77
+ Then either describe your project idea in natural language, or use a specific command:
48
78
 
49
79
  | Command | Description |
50
80
  |---------|-------------|
@@ -57,43 +87,133 @@ Then either:
57
87
  | `*resume` | Resume from last saved checkpoint |
58
88
  | `*change "{desc}"` | Handle mid-project requirement change |
59
89
 
60
- ## Architecture
90
+ ### Telegram Mode
91
+
92
+ Start the gateway and chat with Yuri via your Telegram bot:
93
+
94
+ ```bash
95
+ # With token as CLI argument
96
+ orchestrix-yuri serve --telegram-token "YOUR_BOT_TOKEN"
61
97
 
98
+ # Or configure in ~/.yuri/config/channels.yaml first
99
+ orchestrix-yuri serve
62
100
  ```
63
- ~/.claude/skills/yuri/ ← Globally installed skill
64
- ├── SKILL.md ← Agent persona + activation protocol
65
- ├── tasks/ ← Phase workflow instructions
66
- ├── scripts/ ← Shell scripts (tmux control, monitoring)
67
- ├── templates/ ← Memory schema
68
- ├── data/ ← Decision rules (deployment, routing)
69
- └── resources/ ← Files copied to new projects
101
+
102
+ #### Channel Configuration
103
+
104
+ Edit `~/.yuri/config/channels.yaml`:
105
+
106
+ ```yaml
107
+ server:
108
+ port: 7890
109
+
110
+ channels:
111
+ telegram:
112
+ enabled: true
113
+ token: "YOUR_BOT_TOKEN"
114
+ mode: polling
115
+ owner_chat_id: "" # Auto-bound on first /start
116
+
117
+ engine:
118
+ skill: yuri
119
+ tmux_session: yuri-gateway
120
+ startup_timeout: 30000
121
+ poll_interval: 2000
122
+ timeout: 300000
123
+ autocompact_pct: 80
124
+ compact_every: 50
70
125
  ```
71
126
 
127
+ #### First-time Telegram setup
128
+
129
+ 1. Start the gateway: `orchestrix-yuri serve --telegram-token "YOUR_TOKEN"`
130
+ 2. Open Telegram, find your bot, send `/start`
131
+ 3. The first user to send `/start` becomes the owner (all others are rejected)
132
+ 4. Send any message to interact with Yuri
133
+
134
+ ## Architecture
135
+
136
+ ### Persistent tmux Engine
137
+
138
+ The Telegram gateway runs Claude Code in a persistent tmux session (`yuri-gateway`), not as a one-shot subprocess. This means:
139
+
140
+ - **MCP servers connect once** and stay connected across messages
141
+ - **Conversation context is preserved** natively by Claude Code
142
+ - **No cold-start per message** — only the first message incurs startup latency
143
+
144
+ State detection uses Claude Code's TUI indicators:
145
+
146
+ | Symbol | State | Description |
147
+ |--------|-------|-------------|
148
+ | `○` | Idle | Waiting for input |
149
+ | `●` | Processing | Generating a response |
150
+ | `◐` | Approval | Permission prompt (auto-approved) |
151
+ | `[Verb]ed for Ns` | Complete | Response finished (e.g. "Baked for 31s") |
152
+
153
+ ### Context Management (3-layer)
154
+
155
+ 1. **CLAUDE.md persistence** — core instructions are written to project CLAUDE.md, which survives auto-compact
156
+ 2. **Early auto-compact** — `CLAUDE_AUTOCOMPACT_PCT_OVERRIDE=80` triggers compaction at 80% (not the default 95%)
157
+ 3. **Proactive /compact** — every 50 messages, a `/compact` is sent to keep context lean
158
+
72
159
  ### Memory System
73
160
 
74
- Yuri maintains per-project state in `.yuri/memory.yaml`, enabling:
75
- - **Resumption**: Pick up from any interruption point
76
- - **Progress tracking**: Real-time story/epic completion counts
77
- - **Change management**: History of requirement changes and actions taken
78
- - **Error recovery**: Automatic retry with escalation
161
+ Yuri maintains a four-layer global memory at `~/.yuri/`:
162
+
163
+ ```
164
+ ~/.yuri/
165
+ ├── self.yaml # Yuri identity
166
+ ├── boss/
167
+ │ ├── profile.yaml # Boss profile
168
+ │ └── preferences.yaml # Boss preferences
169
+ ├── portfolio/
170
+ │ ├── registry.yaml # All projects (active/archived)
171
+ │ ├── priorities.yaml # Portfolio priorities
172
+ │ └── relationships.yaml # Project relationships
173
+ ├── focus.yaml # Current focus & state
174
+ ├── config/
175
+ │ └── channels.yaml # Gateway channel config
176
+ ├── chat-history/ # JSONL per chat_id
177
+ ├── inbox.jsonl # Observation signals
178
+ └── wisdom/ # Accumulated knowledge
179
+ ```
79
180
 
80
181
  ### tmux Sessions
81
182
 
82
183
  | Session | Purpose | Windows |
83
184
  |---------|---------|---------|
185
+ | `yuri-gateway` | Telegram channel gateway | 1 (Claude Code interactive) |
84
186
  | `op-{project}` | Planning phase | One per agent (Analyst, PM, UX, Architect, PO) |
85
187
  | `orchestrix-{repo-id}` | Development phase | 4 fixed (Architect, SM, Dev, QA) |
86
188
 
87
- Sessions are **lazily created and recreated** — if a session dies or is killed, Yuri automatically rebuilds it when needed.
88
-
89
- ### Completion Detection
189
+ ### File Structure
90
190
 
91
- Yuri monitors agent completion in tmux panes using a priority-based detection system:
92
-
93
- 1. **Claude Code completion message**: Pattern like "Baked for 31s" (`[A-Z][a-z]*ed for [0-9]`)
94
- 2. **TUI idle indicator**: `○` symbol
95
- 3. **Approval prompt**: `◐` → auto-approved
96
- 4. **Content stability**: Pane unchanged for 90 seconds
191
+ ```
192
+ orchestrix-yuri/
193
+ ├── bin/
194
+ │ ├── install.js # CLI entry (install / serve / migrate)
195
+ │ └── serve.js # Gateway launcher
196
+ ├── lib/
197
+ │ ├── installer.js # Global install logic
198
+ │ ├── migrate.js # v1 → v2 memory migration
199
+ │ └── gateway/
200
+ │ ├── index.js # startGateway()
201
+ │ ├── config.js # Config loading + defaults
202
+ │ ├── router.js # Message routing + 5-engine orchestration
203
+ │ ├── binding.js # Owner authentication
204
+ │ ├── history.js # Chat history (JSONL)
205
+ │ ├── channels/
206
+ │ │ └── telegram.js # grammy Telegram adapter
207
+ │ └── engine/
208
+ │ └── claude-tmux.js # Persistent tmux session engine
209
+ └── skill/
210
+ ├── SKILL.md # Agent persona
211
+ ├── tasks/ # Phase workflow instructions
212
+ ├── scripts/ # Shell scripts (tmux, monitoring)
213
+ ├── templates/ # Memory schema
214
+ ├── data/ # Decision rules
215
+ └── resources/ # MCP config, hooks, tmux scripts
216
+ ```
97
217
 
98
218
  ## Change Management
99
219
 
@@ -117,6 +237,25 @@ Yuri handles mid-project changes based on scope:
117
237
  | Overseas | Railway | Backend APIs |
118
238
  | Overseas | AWS / GCP | Enterprise |
119
239
 
240
+ ## Troubleshooting
241
+
242
+ ```bash
243
+ # Check prerequisites
244
+ tmux -V && which claude && node -v
245
+
246
+ # View gateway logs (all output goes to stdout)
247
+ orchestrix-yuri serve --telegram-token "..." 2>&1 | tee gateway.log
248
+
249
+ # Check if tmux session is alive
250
+ tmux ls
251
+
252
+ # Peek into the Claude Code session
253
+ tmux attach -t yuri-gateway
254
+
255
+ # Manual cleanup if session gets stuck
256
+ tmux kill-session -t yuri-gateway
257
+ ```
258
+
120
259
  ## License
121
260
 
122
261
  MIT
@@ -19,6 +19,15 @@ const DEFAULTS = {
19
19
  },
20
20
  engine: {
21
21
  skill: 'yuri',
22
+ tmux_session: 'yuri-gateway',
23
+ startup_timeout: 30000, // ms to wait for Claude Code to initialize
24
+ poll_interval: 2000, // ms between capture-pane polls
25
+ stable_count: 3, // consecutive stable polls before declaring done
26
+ max_retries: 3, // session restart retries before error
27
+ timeout: 300000, // per-message timeout (5 min)
28
+ history_limit: 10000, // tmux scrollback lines
29
+ autocompact_pct: 80, // trigger auto-compact at this % (default 95%)
30
+ compact_every: 50, // proactive /compact after N messages
22
31
  },
23
32
  };
24
33
 
@@ -0,0 +1,674 @@
1
+ 'use strict';
2
+
3
+ const { execSync } = require('child_process');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const os = require('os');
7
+ const crypto = require('crypto');
8
+ const yaml = require('js-yaml');
9
+
10
+ const YURI_GLOBAL = path.join(os.homedir(), '.yuri');
11
+
12
+ // ── Shared Utilities (formerly in claude-cli.js) ───────────────────────────────
13
+
14
+ /**
15
+ * Load L1 (global context) files and compose them into a context block.
16
+ * Injected into the prompt so Claude does not need to "remember" to read them.
17
+ */
18
+ function loadL1Context() {
19
+ const files = [
20
+ { label: 'Yuri Identity', path: path.join(YURI_GLOBAL, 'self.yaml') },
21
+ { label: 'Boss Profile', path: path.join(YURI_GLOBAL, 'boss', 'profile.yaml') },
22
+ { label: 'Boss Preferences', path: path.join(YURI_GLOBAL, 'boss', 'preferences.yaml') },
23
+ { label: 'Portfolio Registry', path: path.join(YURI_GLOBAL, 'portfolio', 'registry.yaml') },
24
+ { label: 'Global Focus', path: path.join(YURI_GLOBAL, 'focus.yaml') },
25
+ ];
26
+
27
+ const sections = [];
28
+ for (const f of files) {
29
+ if (fs.existsSync(f.path)) {
30
+ const content = fs.readFileSync(f.path, 'utf8').trim();
31
+ if (content) {
32
+ sections.push(`### ${f.label}\n\`\`\`yaml\n${content}\n\`\`\``);
33
+ }
34
+ }
35
+ }
36
+
37
+ return sections.length > 0
38
+ ? `## Yuri Global Memory (L1 — pre-loaded)\n\n${sections.join('\n\n')}`
39
+ : '';
40
+ }
41
+
42
+ /**
43
+ * Determine which project the message likely relates to, based on portfolio.
44
+ */
45
+ function resolveProjectRoot() {
46
+ const registryPath = path.join(YURI_GLOBAL, 'portfolio', 'registry.yaml');
47
+ if (!fs.existsSync(registryPath)) return null;
48
+
49
+ const registry = yaml.load(fs.readFileSync(registryPath, 'utf8')) || {};
50
+ const projects = registry.projects || [];
51
+ const active = projects.filter((p) => p.status === 'active');
52
+
53
+ if (active.length === 0) return null;
54
+
55
+ // Check global focus for active project
56
+ const focusPath = path.join(YURI_GLOBAL, 'focus.yaml');
57
+ if (fs.existsSync(focusPath)) {
58
+ const focus = yaml.load(fs.readFileSync(focusPath, 'utf8')) || {};
59
+ if (focus.active_project) {
60
+ const match = active.find((p) => p.id === focus.active_project);
61
+ if (match && fs.existsSync(match.root)) return match.root;
62
+ }
63
+ }
64
+
65
+ // Fallback: first active project
66
+ if (active[0] && fs.existsSync(active[0].root)) return active[0].root;
67
+
68
+ return null;
69
+ }
70
+
71
+ /**
72
+ * Find the claude binary path.
73
+ * Shell aliases (like `cc`) are not available in child_process, so we
74
+ * resolve the actual binary via the user's login shell PATH.
75
+ */
76
+ function findClaudeBinary() {
77
+ // Primary: resolve via user's login shell (handles all install methods)
78
+ try {
79
+ const resolved = execSync('zsh -lc "which claude" 2>/dev/null', { encoding: 'utf8' }).trim();
80
+ if (resolved && fs.existsSync(resolved)) {
81
+ return resolved;
82
+ }
83
+ } catch {
84
+ // fall through
85
+ }
86
+
87
+ // Fallback: check common install locations
88
+ const candidates = [
89
+ '/usr/local/bin/claude',
90
+ '/opt/homebrew/bin/claude',
91
+ path.join(os.homedir(), '.npm-global', 'bin', 'claude'),
92
+ path.join(os.homedir(), '.local', 'bin', 'claude'),
93
+ path.join(os.homedir(), '.claude', 'bin', 'claude'),
94
+ ];
95
+
96
+ for (const candidate of candidates) {
97
+ if (fs.existsSync(candidate)) {
98
+ return candidate;
99
+ }
100
+ }
101
+
102
+ // Last resort: let the shell find it
103
+ return 'claude';
104
+ }
105
+
106
+ // Cache the binary path
107
+ let _claudeBinary = null;
108
+ function getClaudeBinary() {
109
+ if (!_claudeBinary) {
110
+ _claudeBinary = findClaudeBinary();
111
+ console.log(`[claude-tmux] Using binary: ${_claudeBinary}`);
112
+ }
113
+ return _claudeBinary;
114
+ }
115
+
116
+ // ── Session Configuration ──────────────────────────────────────────────────────
117
+
118
+ const DEFAULT_SESSION = 'yuri-gateway';
119
+ const HISTORY_LIMIT = 10000;
120
+
121
+ // ── Singleton State ────────────────────────────────────────────────────────────
122
+
123
+ let _sessionName = null;
124
+ let _sessionReady = false;
125
+ let _initPromise = null;
126
+ let _messageQueue = Promise.resolve();
127
+ let _messageCount = 0; // messages since last compact/session start
128
+
129
+ // ── Utilities ──────────────────────────────────────────────────────────────────
130
+
131
+ function tmux(cmd) {
132
+ return execSync(`tmux ${cmd}`, { encoding: 'utf8', timeout: 10000 }).trim();
133
+ }
134
+
135
+ function tmuxSafe(cmd) {
136
+ try {
137
+ return tmux(cmd);
138
+ } catch {
139
+ return null;
140
+ }
141
+ }
142
+
143
+ // ── Claude Code TUI Indicators ─────────────────────────────────────────────────
144
+ //
145
+ // Claude Code uses three circle symbols as primary state indicators:
146
+ //
147
+ // ○ (U+25CB) IDLE — Claude is waiting for user input
148
+ // ● (U+25CF) PROCESSING — Claude is actively generating a response
149
+ // ◐ (U+25D0) APPROVAL — Claude is waiting for permission approval
150
+ //
151
+ // During processing, a Braille spinner animates: ⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏
152
+ // with cycling verbs like "Baking...", "Computing...", "Thinking..."
153
+ //
154
+ // Completion message format (past-tense verb + duration):
155
+ // "Baked for 31s", "Worked for 2m 45s", "Cooked for 1m 6s"
156
+ // Pattern: /[A-Z][a-z]*ed for \d+/
157
+ //
158
+ // ────────────────────────────────────────────────────────────────────────────────
159
+
160
+ const BRAILLE_SPINNER = /[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/;
161
+ const COMPLETION_RE = /[A-Z][a-z]*ed for \d+/;
162
+ const IDLE_RE = /○/;
163
+ const PROCESSING_RE = /●/;
164
+ const APPROVAL_RE = /◐/;
165
+
166
+ /**
167
+ * Strip TUI chrome from captured pane output.
168
+ * `tmux capture-pane -p` (without -e) already strips most ANSI codes,
169
+ * but we clean up residual artifacts and Claude Code UI elements.
170
+ */
171
+ function stripChrome(raw) {
172
+ return raw
173
+ .replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '') // ANSI CSI escapes
174
+ .replace(/\x1B\].*?\x07/g, '') // OSC sequences
175
+ .replace(/[○●◐◑]/g, '') // TUI state indicators
176
+ .replace(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/g, '') // Braille spinner frames
177
+ .replace(/[⏵━─█·…→❯]/g, '') // UI decoration chars
178
+ .replace(/^\s*\d+\s*[│|]\s*/gm, '') // line-number gutter
179
+ .replace(/^.*[A-Z][a-z]*ed for \d+.*$/gm, '') // completion stats (all verbs)
180
+ .replace(/^.*[A-Z][a-z]*ing\.{3}.*$/gm, '') // spinner verb lines ("Baking...")
181
+ .replace(/^\s*$/gm, '') // blank lines
182
+ .trim();
183
+ }
184
+
185
+ // ── Session Lifecycle ──────────────────────────────────────────────────────────
186
+
187
+ function hasSession(name) {
188
+ return tmuxSafe(`has-session -t ${name} 2>/dev/null`) !== null;
189
+ }
190
+
191
+ function capturePaneRaw(name, lines) {
192
+ return tmuxSafe(`capture-pane -t ${name}:0 -p -S -${lines || 500}`) || '';
193
+ }
194
+
195
+ /**
196
+ * Get the last N lines of the pane output for state detection.
197
+ */
198
+ function paneTail(name, n) {
199
+ return capturePaneRaw(name, n || 10);
200
+ }
201
+
202
+ /**
203
+ * Detect Claude Code's current state from pane output.
204
+ *
205
+ * @returns {'idle'|'processing'|'approval'|'complete'|'unknown'}
206
+ */
207
+ function detectState(name) {
208
+ const tail = paneTail(name, 15);
209
+
210
+ // Priority 1: Completion message — most reliable signal
211
+ // e.g. "Baked for 31s", "Worked for 2m 45s"
212
+ if (COMPLETION_RE.test(tail)) {
213
+ return 'complete';
214
+ }
215
+
216
+ // Priority 2: Approval prompt — needs immediate response
217
+ if (APPROVAL_RE.test(tail)) {
218
+ return 'approval';
219
+ }
220
+
221
+ // Priority 3: Idle indicator — waiting for input
222
+ if (IDLE_RE.test(tail)) {
223
+ return 'idle';
224
+ }
225
+
226
+ // Priority 4: Processing indicator — still working
227
+ if (PROCESSING_RE.test(tail) || BRAILLE_SPINNER.test(tail)) {
228
+ return 'processing';
229
+ }
230
+
231
+ return 'unknown';
232
+ }
233
+
234
+ /**
235
+ * Detect if Claude Code is idle (ready for input).
236
+ * Checks for ○ idle indicator or completion message.
237
+ */
238
+ function isIdle(name) {
239
+ const state = detectState(name);
240
+ return state === 'idle' || state === 'complete';
241
+ }
242
+
243
+ /**
244
+ * Detect if Claude Code is showing an approval prompt (◐).
245
+ */
246
+ function isApprovalPrompt(name) {
247
+ return detectState(name) === 'approval';
248
+ }
249
+
250
+ /**
251
+ * Detect if Claude Code is actively processing (● or spinner).
252
+ */
253
+ function isProcessing(name) {
254
+ return detectState(name) === 'processing';
255
+ }
256
+
257
+ // ── Context Management ─────────────────────────────────────────────────────────
258
+ //
259
+ // Claude Code has built-in auto-compact that triggers at ~95% context capacity.
260
+ // We improve on this with a 3-layer strategy:
261
+ //
262
+ // Layer 1: CLAUDE.md persistence
263
+ // Channel Mode Instructions are written to the project's CLAUDE.md.
264
+ // CLAUDE.md survives compaction — it's re-read from disk after compact.
265
+ // This means our core instructions are never lost.
266
+ //
267
+ // Layer 2: CLAUDE_AUTOCOMPACT_PCT_OVERRIDE=80
268
+ // Set at session launch to trigger auto-compact at 80% instead of 95%.
269
+ // This gives a comfortable buffer before context pressure causes issues.
270
+ //
271
+ // Layer 3: Proactive /compact
272
+ // After every N messages (configurable, default 50), we proactively
273
+ // send /compact to keep the context lean. This prevents gradual
274
+ // degradation in response quality from context bloat.
275
+ //
276
+ // Session rebuild is only used as a last resort when the session crashes.
277
+ // ────────────────────────────────────────────────────────────────────────────────
278
+
279
+ const CHANNEL_MODE_INSTRUCTIONS = [
280
+ '## Channel Mode (Yuri Gateway)',
281
+ '',
282
+ 'You are responding via a messaging channel (Telegram/Feishu), not a terminal.',
283
+ '- Keep responses concise and mobile-friendly.',
284
+ '- Use markdown formatting sparingly (Telegram supports basic markdown).',
285
+ '- If you need to perform operations, do so and report the result.',
286
+ '- At the end of your response, if you observed any memory-worthy signals',
287
+ ' (user preferences, priority changes, tech lessons, corrections),',
288
+ ' write them to ~/.yuri/inbox.jsonl.',
289
+ '- Update ~/.yuri/focus.yaml and the project\'s focus.yaml after any operation.',
290
+ ].join('\n');
291
+
292
+ /**
293
+ * Ensure Channel Mode Instructions exist in the project's CLAUDE.md.
294
+ * This guarantees instructions survive auto-compact (CLAUDE.md is re-read from disk).
295
+ */
296
+ function ensureClaudeMd(projectRoot) {
297
+ if (!projectRoot) return;
298
+
299
+ const claudeMdPath = path.join(projectRoot, 'CLAUDE.md');
300
+ const marker = '## Channel Mode (Yuri Gateway)';
301
+
302
+ let content = '';
303
+ if (fs.existsSync(claudeMdPath)) {
304
+ content = fs.readFileSync(claudeMdPath, 'utf8');
305
+ if (content.includes(marker)) return; // already present
306
+ }
307
+
308
+ // Append channel mode instructions
309
+ const separator = content.trim() ? '\n\n' : '';
310
+ fs.writeFileSync(claudeMdPath, content + separator + CHANNEL_MODE_INSTRUCTIONS + '\n');
311
+ console.log(`[claude-tmux] Channel Mode Instructions written to ${claudeMdPath}`);
312
+ }
313
+
314
+ /**
315
+ * Send /compact to Claude Code to proactively free context space.
316
+ * Returns true if compact completed successfully.
317
+ */
318
+ async function proactiveCompact(name) {
319
+ console.log('[claude-tmux] Proactive /compact triggered');
320
+ injectMessage(name, '/compact focus on the most recent user conversation and any pending operations');
321
+
322
+ const ok = await waitForIdle(name, 120000); // compact can take up to 2min
323
+ if (ok) {
324
+ _messageCount = 0;
325
+ console.log('[claude-tmux] Proactive /compact completed');
326
+ } else {
327
+ console.warn('[claude-tmux] Proactive /compact timed out');
328
+ }
329
+ return ok;
330
+ }
331
+
332
+ /**
333
+ * Create a new tmux session and start Claude Code inside it.
334
+ */
335
+ async function createSession(engineConfig) {
336
+ const sessionName = engineConfig.tmux_session || DEFAULT_SESSION;
337
+ _sessionName = sessionName;
338
+ _sessionReady = false;
339
+ _messageCount = 0;
340
+
341
+ const binary = getClaudeBinary();
342
+ const projectRoot = resolveProjectRoot() || os.homedir();
343
+
344
+ // Ensure CLAUDE.md has channel mode instructions (survives compact)
345
+ ensureClaudeMd(projectRoot);
346
+
347
+ // Kill existing stale session
348
+ if (hasSession(sessionName)) {
349
+ tmuxSafe(`kill-session -t ${sessionName}`);
350
+ }
351
+
352
+ // Create session with generous scrollback
353
+ tmux(`new-session -d -s ${sessionName} -n claude -c "${projectRoot}"`);
354
+ tmux(`set-option -t ${sessionName} history-limit ${HISTORY_LIMIT}`);
355
+
356
+ // Set auto-compact threshold to 80% (default is 95%)
357
+ // This gives comfortable buffer before context pressure
358
+ const compactPct = engineConfig.autocompact_pct || 80;
359
+ tmux(`send-keys -t ${sessionName}:0 'export CLAUDE_AUTOCOMPACT_PCT_OVERRIDE=${compactPct}' Enter`);
360
+
361
+ // Launch Claude Code in interactive mode
362
+ tmux(`send-keys -t ${sessionName}:0 '"${binary}" --dangerously-skip-permissions' Enter`);
363
+
364
+ // Wait for Claude Code to initialize (detect idle indicator)
365
+ const startupTimeout = engineConfig.startup_timeout || 30000;
366
+ const started = await waitForIdle(sessionName, startupTimeout);
367
+ if (!started) {
368
+ throw new Error(`Claude Code did not become idle within ${startupTimeout}ms`);
369
+ }
370
+
371
+ // Send L1 context as the initial system message.
372
+ // Channel Mode Instructions are already in CLAUDE.md (survives compact),
373
+ // so we only inject L1 global memory here to prime the session.
374
+ const l1 = loadL1Context();
375
+ if (l1) {
376
+ await injectMessage(sessionName, l1);
377
+ await waitForIdle(sessionName, 120000); // allow up to 2min for L1 processing
378
+ }
379
+
380
+ _sessionReady = true;
381
+ console.log(`[claude-tmux] Session "${sessionName}" ready (cwd: ${projectRoot})`);
382
+ }
383
+
384
+ /**
385
+ * Wait for Claude Code to become idle.
386
+ * @returns {Promise<boolean>} true if idle detected, false if timeout
387
+ */
388
+ function waitForIdle(name, timeoutMs) {
389
+ const pollInterval = 2000;
390
+ return new Promise((resolve) => {
391
+ const deadline = Date.now() + timeoutMs;
392
+ const poll = () => {
393
+ if (Date.now() > deadline) {
394
+ return resolve(false);
395
+ }
396
+ if (!hasSession(name)) {
397
+ return resolve(false);
398
+ }
399
+
400
+ // Auto-approve any permission prompts
401
+ if (isApprovalPrompt(name)) {
402
+ tmuxSafe(`send-keys -t ${name}:0 'y' Enter`);
403
+ }
404
+
405
+ if (isIdle(name)) {
406
+ return resolve(true);
407
+ }
408
+ setTimeout(poll, pollInterval);
409
+ };
410
+ setTimeout(poll, pollInterval); // initial delay
411
+ });
412
+ }
413
+
414
+ /**
415
+ * Inject a message into the tmux pane via load-buffer (avoids shell escaping issues).
416
+ */
417
+ function injectMessage(name, text) {
418
+ const tmpFile = path.join(os.tmpdir(), `yuri-tmux-msg-${Date.now()}.txt`);
419
+ fs.writeFileSync(tmpFile, text);
420
+
421
+ try {
422
+ tmux(`load-buffer -b yuri-input "${tmpFile}"`);
423
+ tmux(`paste-buffer -b yuri-input -t ${name}:0`);
424
+ tmux(`send-keys -t ${name}:0 Enter`);
425
+ } finally {
426
+ try { fs.unlinkSync(tmpFile); } catch {}
427
+ }
428
+ }
429
+
430
+ /**
431
+ * Capture the response after injecting a message.
432
+ *
433
+ * Detection priority (mirrors monitor-agent.sh):
434
+ * P1: Completion message — "[Verb]ed for [N]s/m" (e.g. "Baked for 31s")
435
+ * P2: Idle indicator — ○ appears in pane tail
436
+ * P3: Approval prompt — ◐ detected, auto-approve with 'y'
437
+ * P4: Content stability — 3 consecutive polls with identical MD5 hash
438
+ */
439
+ async function captureResponse(name, marker, engineConfig) {
440
+ const timeout = engineConfig.timeout || 300000;
441
+ const pollInterval = engineConfig.poll_interval || 2000;
442
+ const stableThreshold = engineConfig.stable_count || 3;
443
+
444
+ const deadline = Date.now() + timeout;
445
+ let lastHash = '';
446
+ let stableCount = 0;
447
+ let sawProcessing = false;
448
+
449
+ return new Promise((resolve) => {
450
+ const poll = () => {
451
+ // Timeout: return whatever we have
452
+ if (Date.now() > deadline) {
453
+ console.warn('[claude-tmux] Response capture timed out');
454
+ const raw = capturePaneRaw(name, 500);
455
+ return resolve(extractResponse(raw, marker));
456
+ }
457
+
458
+ // Session died
459
+ if (!hasSession(name)) {
460
+ return resolve({ reply: '❌ Claude Code session terminated unexpectedly.', raw: '' });
461
+ }
462
+
463
+ const state = detectState(name);
464
+ const raw = capturePaneRaw(name, 500);
465
+ const hash = crypto.createHash('md5').update(raw).digest('hex');
466
+
467
+ // Track that Claude has started processing (● appeared)
468
+ // This prevents premature completion detection if ○ is still visible
469
+ // from the previous idle state before Claude begins processing.
470
+ if (state === 'processing') {
471
+ sawProcessing = true;
472
+ stableCount = 0;
473
+ lastHash = hash;
474
+ return setTimeout(poll, pollInterval);
475
+ }
476
+
477
+ // P3: Auto-approve permission prompts (◐)
478
+ if (state === 'approval') {
479
+ tmuxSafe(`send-keys -t ${name}:0 'y' Enter`);
480
+ sawProcessing = true; // approval implies processing started
481
+ stableCount = 0;
482
+ lastHash = hash;
483
+ // Brief pause after approval before next poll
484
+ return setTimeout(poll, 2000);
485
+ }
486
+
487
+ // P1: Completion message — most reliable done signal
488
+ if (state === 'complete' && sawProcessing) {
489
+ return resolve(extractResponse(raw, marker));
490
+ }
491
+
492
+ // P2: Idle indicator — done if we saw processing start
493
+ if (state === 'idle' && sawProcessing) {
494
+ return resolve(extractResponse(raw, marker));
495
+ }
496
+
497
+ // P4: Content stability fallback
498
+ if (hash === lastHash) {
499
+ stableCount++;
500
+ } else {
501
+ stableCount = 0;
502
+ lastHash = hash;
503
+ }
504
+
505
+ if (stableCount >= stableThreshold && sawProcessing) {
506
+ console.log('[claude-tmux] Response detected via content stability');
507
+ return resolve(extractResponse(raw, marker));
508
+ }
509
+
510
+ setTimeout(poll, pollInterval);
511
+ };
512
+
513
+ // Initial delay: give Claude time to start processing
514
+ // before first poll (avoids false-positive idle detection)
515
+ setTimeout(poll, Math.max(pollInterval, 3000));
516
+ });
517
+ }
518
+
519
+ /**
520
+ * Extract the assistant's response from captured pane output.
521
+ * Finds the marker, takes everything after it, strips chrome.
522
+ */
523
+ function extractResponse(raw, marker) {
524
+ const lines = raw.split('\n');
525
+ let markerIdx = -1;
526
+
527
+ // Find the last occurrence of the marker (in case of scrollback)
528
+ for (let i = lines.length - 1; i >= 0; i--) {
529
+ if (lines[i].includes(marker)) {
530
+ markerIdx = i;
531
+ break;
532
+ }
533
+ }
534
+
535
+ let responseText;
536
+ if (markerIdx >= 0) {
537
+ // Skip the marker line and any immediate echo of the user message
538
+ const afterMarker = lines.slice(markerIdx + 1).join('\n');
539
+ responseText = stripChrome(afterMarker);
540
+ } else {
541
+ // Fallback: take last chunk of output, strip chrome
542
+ const tail = lines.slice(-100).join('\n');
543
+ responseText = stripChrome(tail);
544
+ }
545
+
546
+ // Trim trailing idle indicators and empty lines
547
+ responseText = responseText
548
+ .replace(/[○●◐◑]\s*$/g, '')
549
+ .replace(/\n{3,}/g, '\n\n')
550
+ .trim();
551
+
552
+ return { reply: responseText || '(no response captured)', raw };
553
+ }
554
+
555
+ // ── Public API ─────────────────────────────────────────────────────────────────
556
+
557
+ /**
558
+ * Ensure the tmux session is alive and ready.
559
+ * Lazy-initializes on first call. Restarts if session died.
560
+ */
561
+ async function ensureSession(engineConfig) {
562
+ if (_sessionName && hasSession(_sessionName) && _sessionReady) {
563
+ return;
564
+ }
565
+
566
+ // Prevent concurrent initialization
567
+ if (_initPromise) {
568
+ return _initPromise;
569
+ }
570
+
571
+ const maxRetries = engineConfig.max_retries || 3;
572
+ _initPromise = (async () => {
573
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
574
+ try {
575
+ await createSession(engineConfig);
576
+ return;
577
+ } catch (err) {
578
+ console.error(`[claude-tmux] Session init attempt ${attempt}/${maxRetries} failed: ${err.message}`);
579
+ if (attempt === maxRetries) throw err;
580
+ // Brief pause before retry
581
+ await new Promise((r) => setTimeout(r, 3000));
582
+ }
583
+ }
584
+ })();
585
+
586
+ try {
587
+ await _initPromise;
588
+ } finally {
589
+ _initPromise = null;
590
+ }
591
+ }
592
+
593
+ /**
594
+ * Send a message to Claude Code via the persistent tmux session.
595
+ *
596
+ * @param {object} opts
597
+ * @param {string} opts.prompt - User message to send
598
+ * @param {string} opts.cwd - Working directory (used for session init, not per-message)
599
+ * @param {object} opts.engineConfig - Engine configuration
600
+ * @param {number} [opts.timeout=300000] - Timeout in ms
601
+ * @returns {Promise<{reply: string, raw: string}>}
602
+ */
603
+ async function callClaude(opts) {
604
+ const { prompt, engineConfig, timeout } = opts;
605
+ const config = { ...engineConfig, timeout: timeout || engineConfig.timeout || 300000 };
606
+
607
+ // Queue messages to prevent concurrent injection into the same pane
608
+ return new Promise((resolve, reject) => {
609
+ _messageQueue = _messageQueue.then(async () => {
610
+ try {
611
+ await ensureSession(config);
612
+
613
+ // Layer 3: Proactive compact after N messages
614
+ const compactEvery = config.compact_every || 50;
615
+ if (_messageCount >= compactEvery) {
616
+ await proactiveCompact(_sessionName);
617
+ }
618
+
619
+ // Generate a unique marker for boundary detection
620
+ const marker = `YURI-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
621
+ const markedPrompt = `[${marker}] ${prompt}`;
622
+
623
+ // Inject and capture
624
+ injectMessage(_sessionName, markedPrompt);
625
+ const result = await captureResponse(_sessionName, marker, config);
626
+
627
+ _messageCount++;
628
+ resolve(result);
629
+ } catch (err) {
630
+ console.error('[claude-tmux] callClaude error:', err.message);
631
+
632
+ // Mark session as not ready so it gets recreated next time
633
+ _sessionReady = false;
634
+ resolve({ reply: `❌ tmux engine error: ${err.message}`, raw: '' });
635
+ }
636
+ }).catch(reject);
637
+ });
638
+ }
639
+
640
+ /**
641
+ * Compose prompt for the persistent session.
642
+ * Only sends the raw user message — the session already has L1 context
643
+ * from initialization, and Claude Code maintains its own conversation history.
644
+ *
645
+ * @param {string} userMessage - The user's message text
646
+ * @param {Array} _chatHistory - Unused (Claude keeps its own context)
647
+ * @returns {string}
648
+ */
649
+ function composePrompt(userMessage, _chatHistory) {
650
+ return userMessage;
651
+ }
652
+
653
+ /**
654
+ * Destroy the tmux session. Called on gateway shutdown.
655
+ */
656
+ function destroySession() {
657
+ if (_sessionName && hasSession(_sessionName)) {
658
+ tmuxSafe(`kill-session -t ${_sessionName}`);
659
+ console.log(`[claude-tmux] Session "${_sessionName}" destroyed.`);
660
+ }
661
+ _sessionName = null;
662
+ _sessionReady = false;
663
+ _initPromise = null;
664
+ }
665
+
666
+ module.exports = {
667
+ callClaude,
668
+ composePrompt,
669
+ loadL1Context,
670
+ resolveProjectRoot,
671
+ findClaudeBinary,
672
+ ensureSession,
673
+ destroySession,
674
+ };
@@ -67,6 +67,7 @@ async function startGateway(opts = {}) {
67
67
  // Graceful shutdown
68
68
  const shutdown = async () => {
69
69
  console.log('\n Shutting down Yuri Gateway...');
70
+ await router.shutdown();
70
71
  for (const adapter of adapters) {
71
72
  await adapter.stop().catch(() => {});
72
73
  }
@@ -5,9 +5,9 @@ const path = require('path');
5
5
  const os = require('os');
6
6
  const yaml = require('js-yaml');
7
7
 
8
- const { callClaude, composePrompt, resolveProjectRoot } = require('./engine/claude-cli');
9
8
  const { ChatHistory } = require('./history');
10
9
  const { OwnerBinding } = require('./binding');
10
+ const engine = require('./engine/claude-tmux');
11
11
 
12
12
  const YURI_GLOBAL = path.join(os.homedir(), '.yuri');
13
13
 
@@ -85,17 +85,17 @@ class Router {
85
85
  // Claude does not need to "remember" to read these files.
86
86
 
87
87
  // ═══ Resolve project context ═══
88
- const projectRoot = resolveProjectRoot();
88
+ const projectRoot = engine.resolveProjectRoot();
89
89
 
90
90
  // ═══ Get chat history for conversation continuity ═══
91
91
  const chatHistory = this.history.getRecent(msg.chatId);
92
92
 
93
93
  // ═══ Compose prompt: L1 context + chat history + user message ═══
94
- const prompt = composePrompt(msg.text, chatHistory);
94
+ const prompt = engine.composePrompt(msg.text, chatHistory);
95
95
 
96
- // ═══ WORK: Call Claude CLI ═══
96
+ // ═══ WORK: Call Claude engine ═══
97
97
  console.log(`[router] Processing: "${msg.text.slice(0, 80)}..." → cwd: ${projectRoot || '~'}`);
98
- const result = await callClaude({
98
+ const result = await engine.callClaude({
99
99
  prompt,
100
100
  cwd: projectRoot,
101
101
  engineConfig: this.config.engine,
@@ -218,6 +218,15 @@ class Router {
218
218
  console.error('[router] Failed to update focus:', err.message);
219
219
  }
220
220
  }
221
+
222
+ /**
223
+ * Graceful shutdown — destroy persistent engine session if active.
224
+ */
225
+ async shutdown() {
226
+ if (engine.destroySession) {
227
+ engine.destroySession();
228
+ }
229
+ }
221
230
  }
222
231
 
223
232
  module.exports = { Router };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orchestrix-yuri",
3
- "version": "2.0.2",
3
+ "version": "2.1.0",
4
4
  "description": "Yuri — Meta-Orchestrator for Orchestrix. Drive your entire project lifecycle with natural language.",
5
5
  "main": "lib/installer.js",
6
6
  "bin": {
@@ -1,225 +0,0 @@
1
- 'use strict';
2
-
3
- const { spawn } = require('child_process');
4
- const fs = require('fs');
5
- const path = require('path');
6
- const os = require('os');
7
- const yaml = require('js-yaml');
8
-
9
- const YURI_GLOBAL = path.join(os.homedir(), '.yuri');
10
-
11
- /**
12
- * Load L1 (global context) files and compose them into a context block.
13
- * This is injected into the prompt so Claude does not need to "remember" to read them.
14
- */
15
- function loadL1Context() {
16
- const files = [
17
- { label: 'Yuri Identity', path: path.join(YURI_GLOBAL, 'self.yaml') },
18
- { label: 'Boss Profile', path: path.join(YURI_GLOBAL, 'boss', 'profile.yaml') },
19
- { label: 'Boss Preferences', path: path.join(YURI_GLOBAL, 'boss', 'preferences.yaml') },
20
- { label: 'Portfolio Registry', path: path.join(YURI_GLOBAL, 'portfolio', 'registry.yaml') },
21
- { label: 'Global Focus', path: path.join(YURI_GLOBAL, 'focus.yaml') },
22
- ];
23
-
24
- const sections = [];
25
- for (const f of files) {
26
- if (fs.existsSync(f.path)) {
27
- const content = fs.readFileSync(f.path, 'utf8').trim();
28
- if (content) {
29
- sections.push(`### ${f.label}\n\`\`\`yaml\n${content}\n\`\`\``);
30
- }
31
- }
32
- }
33
-
34
- return sections.length > 0
35
- ? `## Yuri Global Memory (L1 — pre-loaded)\n\n${sections.join('\n\n')}`
36
- : '';
37
- }
38
-
39
- /**
40
- * Determine which project the message likely relates to, based on portfolio.
41
- */
42
- function resolveProjectRoot() {
43
- const registryPath = path.join(YURI_GLOBAL, 'portfolio', 'registry.yaml');
44
- if (!fs.existsSync(registryPath)) return null;
45
-
46
- const registry = yaml.load(fs.readFileSync(registryPath, 'utf8')) || {};
47
- const projects = registry.projects || [];
48
- const active = projects.filter((p) => p.status === 'active');
49
-
50
- if (active.length === 0) return null;
51
-
52
- // Check global focus for active project
53
- const focusPath = path.join(YURI_GLOBAL, 'focus.yaml');
54
- if (fs.existsSync(focusPath)) {
55
- const focus = yaml.load(fs.readFileSync(focusPath, 'utf8')) || {};
56
- if (focus.active_project) {
57
- const match = active.find((p) => p.id === focus.active_project);
58
- if (match && fs.existsSync(match.root)) return match.root;
59
- }
60
- }
61
-
62
- // Fallback: first active project
63
- if (active[0] && fs.existsSync(active[0].root)) return active[0].root;
64
-
65
- return null;
66
- }
67
-
68
- /**
69
- * Find the claude binary path.
70
- * Shell aliases (like `cc`) are not available in child_process, so we
71
- * resolve the actual binary via the user's login shell PATH.
72
- */
73
- function findClaudeBinary() {
74
- // Primary: resolve via user's login shell (handles all install methods)
75
- try {
76
- const resolved = require('child_process')
77
- .execSync('zsh -lc "which claude" 2>/dev/null', { encoding: 'utf8' })
78
- .trim();
79
- if (resolved && fs.existsSync(resolved)) {
80
- return resolved;
81
- }
82
- } catch {
83
- // fall through
84
- }
85
-
86
- // Fallback: check common install locations
87
- const candidates = [
88
- '/usr/local/bin/claude',
89
- '/opt/homebrew/bin/claude',
90
- path.join(os.homedir(), '.npm-global', 'bin', 'claude'),
91
- path.join(os.homedir(), '.local', 'bin', 'claude'),
92
- path.join(os.homedir(), '.claude', 'bin', 'claude'),
93
- ];
94
-
95
- for (const candidate of candidates) {
96
- if (fs.existsSync(candidate)) {
97
- return candidate;
98
- }
99
- }
100
-
101
- // Last resort: let the shell find it
102
- return 'claude';
103
- }
104
-
105
- // Cache the binary path
106
- let _claudeBinary = null;
107
- function getClaudeBinary() {
108
- if (!_claudeBinary) {
109
- _claudeBinary = findClaudeBinary();
110
- console.log(`[claude-cli] Using binary: ${_claudeBinary}`);
111
- }
112
- return _claudeBinary;
113
- }
114
-
115
- /**
116
- * Execute a Claude CLI call with the Yuri skill.
117
- *
118
- * Uses `claude --dangerously-skip-permissions -p "prompt"` to match the user's
119
- * `cc` alias behavior. The prompt is written to a temp file to avoid command-line
120
- * length limits and shell escaping issues.
121
- *
122
- * @param {object} opts
123
- * @param {string} opts.prompt - The composed prompt
124
- * @param {string} opts.cwd - Working directory (project root)
125
- * @param {object} opts.engineConfig - Engine configuration from channels.yaml
126
- * @param {number} [opts.timeout=300000] - Timeout in ms (default 5 min)
127
- * @returns {Promise<{reply: string, raw: string}>}
128
- */
129
- async function callClaude(opts) {
130
- const { prompt, cwd, engineConfig, timeout = 300000 } = opts;
131
-
132
- const binary = getClaudeBinary();
133
-
134
- // Write prompt to temp file to avoid command-line length limits
135
- const tmpFile = path.join(os.tmpdir(), `yuri-prompt-${Date.now()}.txt`);
136
- fs.writeFileSync(tmpFile, prompt);
137
-
138
- return new Promise((resolve) => {
139
- const args = [
140
- '--dangerously-skip-permissions',
141
- '-p',
142
- `$(cat "${tmpFile}")`,
143
- ];
144
-
145
- // Use shell to expand $(cat ...) and get proper PATH
146
- const child = spawn('zsh', ['-lc', `"${binary}" --dangerously-skip-permissions -p "$(cat "${tmpFile}")"`], {
147
- cwd: cwd || os.homedir(),
148
- env: { ...process.env },
149
- timeout,
150
- stdio: ['ignore', 'pipe', 'pipe'],
151
- });
152
-
153
- let stdout = '';
154
- let stderr = '';
155
-
156
- child.stdout.on('data', (data) => { stdout += data.toString(); });
157
- child.stderr.on('data', (data) => { stderr += data.toString(); });
158
-
159
- child.on('close', (code) => {
160
- // Clean up temp file
161
- try { fs.unlinkSync(tmpFile); } catch {}
162
-
163
- if (stderr.trim()) {
164
- console.error('[claude-cli] stderr:', stderr.trim().slice(0, 500));
165
- }
166
-
167
- if (code !== 0 && !stdout.trim()) {
168
- console.error(`[claude-cli] Process exited with code ${code}`);
169
- resolve({ reply: `❌ Claude CLI error (exit ${code}). Check gateway logs.`, raw: '' });
170
- return;
171
- }
172
-
173
- resolve({ reply: stdout.trim(), raw: stdout });
174
- });
175
-
176
- child.on('error', (err) => {
177
- try { fs.unlinkSync(tmpFile); } catch {}
178
- console.error('[claude-cli] spawn error:', err.message);
179
- resolve({ reply: `❌ Failed to start Claude CLI: ${err.message}`, raw: '' });
180
- });
181
- });
182
- }
183
-
184
- /**
185
- * Compose the full prompt for a channel message.
186
- *
187
- * @param {string} userMessage - The user's message text
188
- * @param {Array} chatHistory - Recent chat messages [{role, text, ts}]
189
- * @returns {string}
190
- */
191
- function composePrompt(userMessage, chatHistory) {
192
- const parts = [];
193
-
194
- // L1 context (pre-loaded global memory)
195
- const l1 = loadL1Context();
196
- if (l1) parts.push(l1);
197
-
198
- // Chat history for conversation continuity
199
- if (chatHistory && chatHistory.length > 0) {
200
- const historyBlock = chatHistory
201
- .map((m) => `**${m.role}** (${m.ts}): ${m.text}`)
202
- .join('\n');
203
- parts.push(`## Recent Conversation\n\n${historyBlock}`);
204
- }
205
-
206
- // User message
207
- parts.push(`## Current Message\n\n${userMessage}`);
208
-
209
- // Instructions for channel mode
210
- parts.push(
211
- `## Channel Mode Instructions\n\n` +
212
- `You are responding via a messaging channel (Telegram/Feishu), not a terminal.\n` +
213
- `- Keep responses concise and mobile-friendly.\n` +
214
- `- Use markdown formatting sparingly (Telegram supports basic markdown).\n` +
215
- `- If you need to perform operations, do so and report the result.\n` +
216
- `- At the end of your response, if you observed any memory-worthy signals ` +
217
- `(user preferences, priority changes, tech lessons, corrections), ` +
218
- `write them to ~/.yuri/inbox.jsonl.\n` +
219
- `- Update ~/.yuri/focus.yaml and the project's focus.yaml after any operation.`
220
- );
221
-
222
- return parts.join('\n\n---\n\n');
223
- }
224
-
225
- module.exports = { callClaude, composePrompt, loadL1Context, resolveProjectRoot, findClaudeBinary };