create-byan-agent 2.9.4 → 2.9.5
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/install/bin/byan-cleanup.js +156 -0
- package/install/bin/byan-kanban.js +159 -0
- package/install/bin/byan-ledger.js +45 -0
- package/install/lib/cleanup/detector.js +154 -0
- package/install/lib/cleanup/executor.js +72 -0
- package/install/lib/subagent-generator.js +208 -0
- package/install/lib/token-ledger.js +131 -0
- package/install/templates/.claude/agents/bmad-bmad-master.md +14 -0
- package/install/templates/.claude/agents/bmad-bmb-agent-builder.md +14 -0
- package/install/templates/.claude/agents/bmad-bmb-module-builder.md +14 -0
- package/install/templates/.claude/agents/bmad-bmb-workflow-builder.md +14 -0
- package/install/templates/.claude/agents/bmad-bmm-analyst.md +14 -0
- package/install/templates/.claude/agents/bmad-bmm-architect.md +14 -0
- package/install/templates/.claude/agents/bmad-bmm-dev.md +14 -0
- package/install/templates/.claude/agents/bmad-bmm-pm.md +14 -0
- package/install/templates/.claude/agents/bmad-bmm-quick-flow-solo-dev.md +14 -0
- package/install/templates/.claude/agents/bmad-bmm-quinn.md +14 -0
- package/install/templates/.claude/agents/bmad-bmm-sm.md +14 -0
- package/install/templates/.claude/agents/bmad-bmm-tech-writer.md +14 -0
- package/install/templates/.claude/agents/bmad-bmm-ux-designer.md +14 -0
- package/install/templates/.claude/agents/bmad-byan-v2.md +14 -0
- package/install/templates/.claude/agents/bmad-byan.md +152 -0
- package/install/templates/.claude/agents/bmad-carmack.md +14 -0
- package/install/templates/.claude/agents/bmad-cis-brainstorming-coach.md +14 -0
- package/install/templates/.claude/agents/bmad-cis-creative-problem-solver.md +14 -0
- package/install/templates/.claude/agents/bmad-cis-design-thinking-coach.md +14 -0
- package/install/templates/.claude/agents/bmad-cis-innovation-strategist.md +14 -0
- package/install/templates/.claude/agents/bmad-cis-presentation-master.md +14 -0
- package/install/templates/.claude/agents/bmad-cis-storyteller.md +14 -0
- package/install/templates/.claude/agents/bmad-claude.md +26 -0
- package/install/templates/.claude/agents/bmad-codex.md +26 -0
- package/install/templates/.claude/agents/bmad-compliance.md +68 -0
- package/install/templates/.claude/agents/bmad-drawio.md +25 -0
- package/install/templates/.claude/agents/bmad-expert-merise-agile.md +54 -0
- package/install/templates/.claude/agents/bmad-fact-checker.md +14 -0
- package/install/templates/.claude/agents/bmad-forgeron.md +14 -0
- package/install/templates/.claude/agents/bmad-hermes.md +59 -0
- package/install/templates/.claude/agents/bmad-marc.md +25 -0
- package/install/templates/.claude/agents/bmad-patnote.md +26 -0
- package/install/templates/.claude/agents/bmad-rachid.md +25 -0
- package/install/templates/.claude/agents/bmad-tao.md +14 -0
- package/install/templates/.claude/agents/bmad-tea-tea.md +14 -0
- package/install/templates/.claude/agents/bmad-yanstaller.md +47 -0
- package/install/templates/.claude/hooks/fact-check-absolutes.js +185 -0
- package/install/templates/.claude/hooks/fd-phase-guard.js +87 -0
- package/install/templates/.claude/hooks/fd-response-check.js +92 -0
- package/install/templates/.claude/hooks/lib/failure-detector.js +14 -0
- package/install/templates/.claude/hooks/pre-compact-save.js +148 -0
- package/install/templates/.claude/hooks/tool-failure-guard.js +6 -0
- package/install/templates/.claude/hooks/tool-transparency.js +4 -0
- package/install/templates/.claude/settings.json +23 -0
- package/install/templates/.claude/skills/byan-byan/SKILL.md +115 -163
- package/install/templates/.claude/skills/byan-orchestrate/SKILL.md +100 -0
- package/install/templates/.githooks/pre-commit +75 -0
- package/install/templates/_byan/mcp/byan-mcp-server/lib/copilot.js +148 -0
- package/install/templates/_byan/mcp/byan-mcp-server/lib/fd-state.js +163 -0
- package/install/templates/_byan/mcp/byan-mcp-server/lib/kanban.js +226 -0
- package/install/templates/_byan/mcp/byan-mcp-server/lib/peer-review.js +187 -0
- package/install/templates/_byan/mcp/byan-mcp-server/server.js +463 -0
- package/install/templates/detector.js +154 -0
- package/package.json +6 -7
- package/src/loadbalancer/capability-matrix.js +157 -0
- package/src/loadbalancer/config.js +141 -0
- package/src/loadbalancer/graceful-degradation.js +212 -0
- package/src/loadbalancer/health-probe.js +151 -0
- package/src/loadbalancer/hooks/claude-hooks.js +53 -0
- package/src/loadbalancer/hooks/copilot-hooks.js +74 -0
- package/src/loadbalancer/index.js +81 -0
- package/src/loadbalancer/loadbalancer.default.yaml +65 -0
- package/src/loadbalancer/loadbalancer.js +324 -0
- package/src/loadbalancer/mcp-server.js +304 -0
- package/src/loadbalancer/metrics.js +146 -0
- package/src/loadbalancer/native/claude-integration.js +64 -0
- package/src/loadbalancer/native/copilot-integration.js +59 -0
- package/src/loadbalancer/pressure-score.js +102 -0
- package/src/loadbalancer/providers/base-provider.js +80 -0
- package/src/loadbalancer/providers/byan-api-provider.js +132 -0
- package/src/loadbalancer/providers/claude-provider.js +113 -0
- package/src/loadbalancer/providers/copilot-provider.js +104 -0
- package/src/loadbalancer/rate-limit-tracker.js +216 -0
- package/src/loadbalancer/session-bridge.js +179 -0
- package/src/loadbalancer/state/db.js +211 -0
- package/src/loadbalancer/state/migrations/001-initial.sql +50 -0
- package/src/loadbalancer/tools/index.js +123 -0
- package/src/loadbalancer/velocity-estimator.js +147 -0
- package/update-byan-agent/bin/update-byan-agent.js +27 -2
- package/API-BYAN-V2.md +0 -741
- package/BMAD-QUICK-REFERENCE.md +0 -370
- package/CHANGELOG-v2.1.0.md +0 -371
- package/MIGRATION-v2.0-to-v2.1.md +0 -430
- package/README-BYAN-V2.md +0 -446
- package/TEST-GUIDE-v2.3.2.md +0 -161
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SessionBridge — Cross-Provider Context Transfer
|
|
3
|
+
*
|
|
4
|
+
* Serializes conversation state from one provider into a provider-agnostic
|
|
5
|
+
* format, then injects it as context into the target provider.
|
|
6
|
+
*
|
|
7
|
+
* Strategy:
|
|
8
|
+
* 1. Extract: conversation messages + files touched + decisions made
|
|
9
|
+
* 2. Summarize: if transcript > max_tokens, use LLM to compress
|
|
10
|
+
* 3. Package: provider-agnostic JSON (PortableContext)
|
|
11
|
+
* 4. Inject: as system prompt prefix on target provider
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {object} PortableContext
|
|
16
|
+
* @property {string} conversationId
|
|
17
|
+
* @property {string} sourceProvider
|
|
18
|
+
* @property {string} targetProvider
|
|
19
|
+
* @property {string} summary - Concise conversation summary
|
|
20
|
+
* @property {string[]} filesTouched - Files read/modified
|
|
21
|
+
* @property {string[]} decisions - Key decisions made
|
|
22
|
+
* @property {string} currentTask - What was being worked on
|
|
23
|
+
* @property {object} agentState - BYAN agent state (soul, tao, FD phase, etc.)
|
|
24
|
+
* @property {number} fidelityPct - Estimated context preservation %
|
|
25
|
+
* @property {string} transferredAt - ISO timestamp
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
class SessionBridge {
|
|
29
|
+
/**
|
|
30
|
+
* @param {object} opts
|
|
31
|
+
* @param {import('./state/db').SharedStateStore} opts.store
|
|
32
|
+
* @param {number} [opts.maxTokens] - Max summary tokens (default: 2000)
|
|
33
|
+
*/
|
|
34
|
+
constructor(opts = {}) {
|
|
35
|
+
this.store = opts.store;
|
|
36
|
+
this.maxTokens = opts.maxTokens || 2000;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Extract context from source provider's session.
|
|
41
|
+
* @param {object} provider - ProviderAdapter instance
|
|
42
|
+
* @param {string} conversationId
|
|
43
|
+
* @returns {Promise<PortableContext>}
|
|
44
|
+
*/
|
|
45
|
+
async extract(provider, conversationId) {
|
|
46
|
+
const conversation = this.store?.getConversation(conversationId);
|
|
47
|
+
const existingContext = conversation?.context_json
|
|
48
|
+
? JSON.parse(conversation.context_json)
|
|
49
|
+
: {};
|
|
50
|
+
|
|
51
|
+
const switchHistory = this.store?.getSwitchoversForConversation(conversationId) || [];
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
conversationId,
|
|
55
|
+
sourceProvider: provider.name,
|
|
56
|
+
targetProvider: null,
|
|
57
|
+
summary: existingContext.summary || 'No conversation summary available.',
|
|
58
|
+
filesTouched: existingContext.filesTouched || [],
|
|
59
|
+
decisions: existingContext.decisions || [],
|
|
60
|
+
currentTask: existingContext.currentTask || 'Unknown task',
|
|
61
|
+
agentState: existingContext.agentState || {},
|
|
62
|
+
switchHistory: switchHistory.map(s => ({
|
|
63
|
+
from: s.from_provider,
|
|
64
|
+
to: s.to_provider,
|
|
65
|
+
reason: s.reason,
|
|
66
|
+
at: s.switched_at,
|
|
67
|
+
})),
|
|
68
|
+
fidelityPct: existingContext.summary ? 85 : 50,
|
|
69
|
+
transferredAt: new Date().toISOString(),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Update the stored context for a conversation.
|
|
75
|
+
* Called periodically during normal operation to keep context fresh.
|
|
76
|
+
* @param {string} conversationId
|
|
77
|
+
* @param {object} contextUpdate - Partial context to merge
|
|
78
|
+
*/
|
|
79
|
+
updateContext(conversationId, contextUpdate) {
|
|
80
|
+
if (!this.store) return;
|
|
81
|
+
|
|
82
|
+
const conversation = this.store.getConversation(conversationId);
|
|
83
|
+
if (!conversation) return;
|
|
84
|
+
|
|
85
|
+
const existing = conversation.context_json
|
|
86
|
+
? JSON.parse(conversation.context_json)
|
|
87
|
+
: {};
|
|
88
|
+
|
|
89
|
+
const merged = {
|
|
90
|
+
...existing,
|
|
91
|
+
...contextUpdate,
|
|
92
|
+
filesTouched: [
|
|
93
|
+
...new Set([...(existing.filesTouched || []), ...(contextUpdate.filesTouched || [])]),
|
|
94
|
+
],
|
|
95
|
+
decisions: [
|
|
96
|
+
...(existing.decisions || []),
|
|
97
|
+
...(contextUpdate.decisions || []),
|
|
98
|
+
],
|
|
99
|
+
updatedAt: new Date().toISOString(),
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
this.store.updateConversation(conversationId, {
|
|
103
|
+
context_json: JSON.stringify(merged),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Format a PortableContext into a system prompt for the target provider.
|
|
109
|
+
* @param {PortableContext} ctx
|
|
110
|
+
* @returns {string} - Formatted context injection prompt
|
|
111
|
+
*/
|
|
112
|
+
formatInjectionPrompt(ctx) {
|
|
113
|
+
const lines = [
|
|
114
|
+
'--- CONTEXT TRANSFER ---',
|
|
115
|
+
`Transferred from: ${ctx.sourceProvider}`,
|
|
116
|
+
`Conversation: ${ctx.conversationId}`,
|
|
117
|
+
`Fidelity: ~${ctx.fidelityPct}%`,
|
|
118
|
+
'',
|
|
119
|
+
'## Summary',
|
|
120
|
+
ctx.summary,
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
if (ctx.currentTask) {
|
|
124
|
+
lines.push('', '## Current Task', ctx.currentTask);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (ctx.filesTouched.length > 0) {
|
|
128
|
+
lines.push('', '## Files Touched', ...ctx.filesTouched.map(f => `- ${f}`));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (ctx.decisions.length > 0) {
|
|
132
|
+
lines.push('', '## Key Decisions', ...ctx.decisions.map(d => `- ${d}`));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (ctx.agentState && Object.keys(ctx.agentState).length > 0) {
|
|
136
|
+
lines.push('', '## Agent State', JSON.stringify(ctx.agentState, null, 2));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (ctx.switchHistory && ctx.switchHistory.length > 0) {
|
|
140
|
+
lines.push(
|
|
141
|
+
'', '## Switch History',
|
|
142
|
+
...ctx.switchHistory.map(s => `- ${s.from} -> ${s.to} (${s.reason}) at ${s.at}`)
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
lines.push('', '--- END CONTEXT TRANSFER ---');
|
|
147
|
+
return lines.join('\n');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Full transfer: extract from source, record switchover, format for target.
|
|
152
|
+
* @param {object} sourceProvider
|
|
153
|
+
* @param {string} targetProviderName
|
|
154
|
+
* @param {string} conversationId
|
|
155
|
+
* @param {string} reason
|
|
156
|
+
* @returns {Promise<{context: PortableContext, injectionPrompt: string}>}
|
|
157
|
+
*/
|
|
158
|
+
async transfer(sourceProvider, targetProviderName, conversationId, reason) {
|
|
159
|
+
const context = await this.extract(sourceProvider, conversationId);
|
|
160
|
+
context.targetProvider = targetProviderName;
|
|
161
|
+
|
|
162
|
+
if (this.store) {
|
|
163
|
+
this.store.recordSwitchover({
|
|
164
|
+
conversationId,
|
|
165
|
+
from: sourceProvider.name,
|
|
166
|
+
to: targetProviderName,
|
|
167
|
+
reason,
|
|
168
|
+
contextSnapshot: JSON.stringify(context),
|
|
169
|
+
fidelityPct: context.fidelityPct,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const injectionPrompt = this.formatInjectionPrompt(context);
|
|
174
|
+
|
|
175
|
+
return { context, injectionPrompt };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
module.exports = { SessionBridge };
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SharedStateStore — SQLite-backed state for LoadBalancer
|
|
3
|
+
*
|
|
4
|
+
* Manages conversations, switchover events, rate limit logs, and
|
|
5
|
+
* provider statistics. Shared between providers via MCP tools.
|
|
6
|
+
*
|
|
7
|
+
* Uses better-sqlite3 for synchronous, fast access.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
|
|
13
|
+
const MIGRATION_DIR = path.join(__dirname, 'migrations');
|
|
14
|
+
|
|
15
|
+
class SharedStateStore {
|
|
16
|
+
/**
|
|
17
|
+
* @param {string} dbPath - Path to SQLite database file
|
|
18
|
+
*/
|
|
19
|
+
constructor(dbPath) {
|
|
20
|
+
this.dbPath = dbPath;
|
|
21
|
+
this.db = null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Initialize database and run migrations.
|
|
26
|
+
*/
|
|
27
|
+
open() {
|
|
28
|
+
let Database;
|
|
29
|
+
try {
|
|
30
|
+
Database = require('better-sqlite3');
|
|
31
|
+
} catch {
|
|
32
|
+
throw new Error('better-sqlite3 required for SharedStateStore. Run: npm install better-sqlite3');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const dir = path.dirname(this.dbPath);
|
|
36
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
37
|
+
|
|
38
|
+
this.db = new Database(this.dbPath);
|
|
39
|
+
this.db.pragma('journal_mode = WAL');
|
|
40
|
+
this.db.pragma('foreign_keys = ON');
|
|
41
|
+
|
|
42
|
+
this._runMigrations();
|
|
43
|
+
return this;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
_runMigrations() {
|
|
47
|
+
const files = fs.readdirSync(MIGRATION_DIR)
|
|
48
|
+
.filter(f => f.endsWith('.sql'))
|
|
49
|
+
.sort();
|
|
50
|
+
|
|
51
|
+
for (const file of files) {
|
|
52
|
+
const sql = fs.readFileSync(path.join(MIGRATION_DIR, file), 'utf8');
|
|
53
|
+
this.db.exec(sql);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// --- Conversations ---
|
|
58
|
+
|
|
59
|
+
createConversation(id, provider) {
|
|
60
|
+
const stmt = this.db.prepare(
|
|
61
|
+
'INSERT INTO lb_conversations (id, active_provider) VALUES (?, ?)'
|
|
62
|
+
);
|
|
63
|
+
stmt.run(id, provider);
|
|
64
|
+
return this.getConversation(id);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
getConversation(id) {
|
|
68
|
+
return this.db.prepare('SELECT * FROM lb_conversations WHERE id = ?').get(id);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
getActiveConversations() {
|
|
72
|
+
return this.db.prepare(
|
|
73
|
+
"SELECT * FROM lb_conversations WHERE status = 'active' ORDER BY updated_at DESC"
|
|
74
|
+
).all();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
updateConversation(id, fields) {
|
|
78
|
+
const allowed = ['active_provider', 'context_json', 'status'];
|
|
79
|
+
const sets = [];
|
|
80
|
+
const values = [];
|
|
81
|
+
|
|
82
|
+
for (const [key, val] of Object.entries(fields)) {
|
|
83
|
+
if (!allowed.includes(key)) continue;
|
|
84
|
+
sets.push(`${key} = ?`);
|
|
85
|
+
values.push(val);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (sets.length === 0) return;
|
|
89
|
+
|
|
90
|
+
sets.push("updated_at = datetime('now')");
|
|
91
|
+
values.push(id);
|
|
92
|
+
|
|
93
|
+
this.db.prepare(
|
|
94
|
+
`UPDATE lb_conversations SET ${sets.join(', ')} WHERE id = ?`
|
|
95
|
+
).run(...values);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// --- Switchovers ---
|
|
99
|
+
|
|
100
|
+
recordSwitchover({ conversationId, from, to, reason, contextSnapshot, fidelityPct }) {
|
|
101
|
+
const stmt = this.db.prepare(`
|
|
102
|
+
INSERT INTO lb_switchovers (conversation_id, from_provider, to_provider, reason, context_snapshot, context_fidelity_pct)
|
|
103
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
104
|
+
`);
|
|
105
|
+
const info = stmt.run(conversationId, from, to, reason, contextSnapshot || null, fidelityPct || null);
|
|
106
|
+
|
|
107
|
+
if (conversationId) {
|
|
108
|
+
this.updateConversation(conversationId, {
|
|
109
|
+
active_provider: to,
|
|
110
|
+
status: 'active',
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
this._updateProviderStat(from, { total_switchovers_from: 1 });
|
|
115
|
+
this._updateProviderStat(to, { total_switchovers_to: 1 });
|
|
116
|
+
|
|
117
|
+
return info.lastInsertRowid;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
getSwitchoverHistory(limit = 20) {
|
|
121
|
+
return this.db.prepare(
|
|
122
|
+
'SELECT * FROM lb_switchovers ORDER BY switched_at DESC LIMIT ?'
|
|
123
|
+
).all(limit);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
getSwitchoversForConversation(conversationId) {
|
|
127
|
+
return this.db.prepare(
|
|
128
|
+
'SELECT * FROM lb_switchovers WHERE conversation_id = ? ORDER BY switched_at'
|
|
129
|
+
).all(conversationId);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// --- Rate Limit Log ---
|
|
133
|
+
|
|
134
|
+
logRateLimit({ provider, eventType, stateBefore, stateAfter, meta }) {
|
|
135
|
+
const stmt = this.db.prepare(`
|
|
136
|
+
INSERT INTO lb_rate_limit_log (provider, event_type, state_before, state_after, meta_json)
|
|
137
|
+
VALUES (?, ?, ?, ?, ?)
|
|
138
|
+
`);
|
|
139
|
+
stmt.run(provider, eventType, stateBefore, stateAfter, meta ? JSON.stringify(meta) : null);
|
|
140
|
+
|
|
141
|
+
if (eventType === '429') {
|
|
142
|
+
this._updateProviderStat(provider, { total_429s: 1 });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
getRateLimitLog(provider, limit = 50) {
|
|
147
|
+
if (provider) {
|
|
148
|
+
return this.db.prepare(
|
|
149
|
+
'SELECT * FROM lb_rate_limit_log WHERE provider = ? ORDER BY logged_at DESC LIMIT ?'
|
|
150
|
+
).all(provider, limit);
|
|
151
|
+
}
|
|
152
|
+
return this.db.prepare(
|
|
153
|
+
'SELECT * FROM lb_rate_limit_log ORDER BY logged_at DESC LIMIT ?'
|
|
154
|
+
).all(limit);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// --- Provider Stats ---
|
|
158
|
+
|
|
159
|
+
recordRequest(provider, latencyMs) {
|
|
160
|
+
this._ensureProviderStat(provider);
|
|
161
|
+
const current = this.db.prepare('SELECT * FROM lb_provider_stats WHERE provider = ?').get(provider);
|
|
162
|
+
|
|
163
|
+
const newTotal = current.total_requests + 1;
|
|
164
|
+
const newAvg = ((current.avg_latency_ms * current.total_requests) + latencyMs) / newTotal;
|
|
165
|
+
|
|
166
|
+
this.db.prepare(`
|
|
167
|
+
UPDATE lb_provider_stats
|
|
168
|
+
SET total_requests = ?, avg_latency_ms = ?, last_used_at = datetime('now'), updated_at = datetime('now')
|
|
169
|
+
WHERE provider = ?
|
|
170
|
+
`).run(newTotal, newAvg, provider);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
getProviderStats() {
|
|
174
|
+
return this.db.prepare('SELECT * FROM lb_provider_stats ORDER BY provider').all();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
_ensureProviderStat(provider) {
|
|
178
|
+
this.db.prepare(
|
|
179
|
+
'INSERT OR IGNORE INTO lb_provider_stats (provider) VALUES (?)'
|
|
180
|
+
).run(provider);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
_updateProviderStat(provider, increments) {
|
|
184
|
+
this._ensureProviderStat(provider);
|
|
185
|
+
const sets = [];
|
|
186
|
+
const values = [];
|
|
187
|
+
|
|
188
|
+
for (const [key, inc] of Object.entries(increments)) {
|
|
189
|
+
sets.push(`${key} = ${key} + ?`);
|
|
190
|
+
values.push(inc);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
sets.push("updated_at = datetime('now')");
|
|
194
|
+
values.push(provider);
|
|
195
|
+
|
|
196
|
+
this.db.prepare(
|
|
197
|
+
`UPDATE lb_provider_stats SET ${sets.join(', ')} WHERE provider = ?`
|
|
198
|
+
).run(...values);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// --- Lifecycle ---
|
|
202
|
+
|
|
203
|
+
close() {
|
|
204
|
+
if (this.db) {
|
|
205
|
+
this.db.close();
|
|
206
|
+
this.db = null;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
module.exports = { SharedStateStore };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
-- LoadBalancer SharedStateStore — Initial Schema
|
|
2
|
+
-- Tracks conversations, switchover events, and rate limit history
|
|
3
|
+
|
|
4
|
+
CREATE TABLE IF NOT EXISTS lb_conversations (
|
|
5
|
+
id TEXT PRIMARY KEY,
|
|
6
|
+
active_provider TEXT NOT NULL,
|
|
7
|
+
started_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
8
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
9
|
+
context_json TEXT,
|
|
10
|
+
status TEXT NOT NULL DEFAULT 'active'
|
|
11
|
+
CHECK (status IN ('active', 'paused', 'completed', 'transferred'))
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
CREATE TABLE IF NOT EXISTS lb_switchovers (
|
|
15
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
16
|
+
conversation_id TEXT,
|
|
17
|
+
from_provider TEXT NOT NULL,
|
|
18
|
+
to_provider TEXT NOT NULL,
|
|
19
|
+
reason TEXT NOT NULL,
|
|
20
|
+
context_snapshot TEXT,
|
|
21
|
+
context_fidelity_pct INTEGER,
|
|
22
|
+
switched_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
23
|
+
FOREIGN KEY (conversation_id) REFERENCES lb_conversations(id)
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
CREATE TABLE IF NOT EXISTS lb_rate_limit_log (
|
|
27
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
28
|
+
provider TEXT NOT NULL,
|
|
29
|
+
event_type TEXT NOT NULL CHECK (event_type IN ('429', 'header_warning', 'retry_after', 'recovery')),
|
|
30
|
+
state_before TEXT NOT NULL,
|
|
31
|
+
state_after TEXT NOT NULL,
|
|
32
|
+
meta_json TEXT,
|
|
33
|
+
logged_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
CREATE TABLE IF NOT EXISTS lb_provider_stats (
|
|
37
|
+
provider TEXT PRIMARY KEY,
|
|
38
|
+
total_requests INTEGER NOT NULL DEFAULT 0,
|
|
39
|
+
total_429s INTEGER NOT NULL DEFAULT 0,
|
|
40
|
+
total_switchovers_from INTEGER NOT NULL DEFAULT 0,
|
|
41
|
+
total_switchovers_to INTEGER NOT NULL DEFAULT 0,
|
|
42
|
+
avg_latency_ms REAL NOT NULL DEFAULT 0,
|
|
43
|
+
last_used_at TEXT,
|
|
44
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
CREATE INDEX IF NOT EXISTS idx_switchovers_conversation ON lb_switchovers(conversation_id);
|
|
48
|
+
CREATE INDEX IF NOT EXISTS idx_switchovers_time ON lb_switchovers(switched_at);
|
|
49
|
+
CREATE INDEX IF NOT EXISTS idx_rate_limit_provider ON lb_rate_limit_log(provider, logged_at);
|
|
50
|
+
CREATE INDEX IF NOT EXISTS idx_conversations_status ON lb_conversations(status);
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LoadBalancer MCP Tool Definitions
|
|
3
|
+
*
|
|
4
|
+
* Tools exposed to both Claude Code and Copilot CLI via MCP protocol.
|
|
5
|
+
* Each tool is a { name, description, inputSchema, handler } object.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
function createTools(lb) {
|
|
9
|
+
return [
|
|
10
|
+
{
|
|
11
|
+
name: 'lb_status',
|
|
12
|
+
description: 'Get current loadbalancer status: active provider, rate limit states, session info.',
|
|
13
|
+
inputSchema: {
|
|
14
|
+
type: 'object',
|
|
15
|
+
properties: {},
|
|
16
|
+
},
|
|
17
|
+
handler: async () => {
|
|
18
|
+
const status = lb.getStatus();
|
|
19
|
+
return { content: [{ type: 'text', text: JSON.stringify(status, null, 2) }] };
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: 'lb_send',
|
|
24
|
+
description: 'Send a prompt through the loadbalancer. Routes to healthiest provider automatically.',
|
|
25
|
+
inputSchema: {
|
|
26
|
+
type: 'object',
|
|
27
|
+
properties: {
|
|
28
|
+
prompt: { type: 'string', description: 'The prompt to send' },
|
|
29
|
+
session_id: { type: 'string', description: 'Optional session ID for sticky routing' },
|
|
30
|
+
prefer_provider: { type: 'string', enum: ['claude', 'copilot', 'auto'], description: 'Provider preference (default: auto)' },
|
|
31
|
+
},
|
|
32
|
+
required: ['prompt'],
|
|
33
|
+
},
|
|
34
|
+
handler: async (args) => {
|
|
35
|
+
const result = await lb.send({
|
|
36
|
+
prompt: args.prompt,
|
|
37
|
+
sessionId: args.session_id,
|
|
38
|
+
preferProvider: args.prefer_provider || 'auto',
|
|
39
|
+
});
|
|
40
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: 'lb_switch',
|
|
45
|
+
description: 'Force switch to a specific provider. Triggers context transfer via SessionBridge.',
|
|
46
|
+
inputSchema: {
|
|
47
|
+
type: 'object',
|
|
48
|
+
properties: {
|
|
49
|
+
target_provider: { type: 'string', enum: ['claude', 'copilot'], description: 'Provider to switch to' },
|
|
50
|
+
session_id: { type: 'string', description: 'Session to transfer context from' },
|
|
51
|
+
reason: { type: 'string', description: 'Reason for the switch' },
|
|
52
|
+
},
|
|
53
|
+
required: ['target_provider'],
|
|
54
|
+
},
|
|
55
|
+
handler: async (args) => {
|
|
56
|
+
const result = await lb.switchProvider({
|
|
57
|
+
target: args.target_provider,
|
|
58
|
+
sessionId: args.session_id,
|
|
59
|
+
reason: args.reason || 'manual_switch',
|
|
60
|
+
});
|
|
61
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: 'lb_get_context',
|
|
66
|
+
description: 'Get the current session context in provider-agnostic format. Useful before manual switch.',
|
|
67
|
+
inputSchema: {
|
|
68
|
+
type: 'object',
|
|
69
|
+
properties: {
|
|
70
|
+
session_id: { type: 'string', description: 'Session to get context for' },
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
handler: async (args) => {
|
|
74
|
+
const context = await lb.getSessionContext(args.session_id);
|
|
75
|
+
return { content: [{ type: 'text', text: JSON.stringify(context, null, 2) }] };
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
name: 'lb_rate_limits',
|
|
80
|
+
description: 'Get detailed rate limit state for all providers. Shows circuit breaker state, 429 counts, reset times.',
|
|
81
|
+
inputSchema: {
|
|
82
|
+
type: 'object',
|
|
83
|
+
properties: {},
|
|
84
|
+
},
|
|
85
|
+
handler: async () => {
|
|
86
|
+
const limits = lb.getRateLimitDetails();
|
|
87
|
+
return { content: [{ type: 'text', text: JSON.stringify(limits, null, 2) }] };
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
name: 'lb_history',
|
|
92
|
+
description: 'Get switchover history. Shows when and why provider switches occurred.',
|
|
93
|
+
inputSchema: {
|
|
94
|
+
type: 'object',
|
|
95
|
+
properties: {
|
|
96
|
+
limit: { type: 'number', description: 'Max events to return (default: 20)' },
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
handler: async (args) => {
|
|
100
|
+
const history = lb.getSwitchoverHistory(args.limit || 20);
|
|
101
|
+
return { content: [{ type: 'text', text: JSON.stringify(history, null, 2) }] };
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
name: 'lb_quota',
|
|
106
|
+
description: 'Get real-time rate limit pressure per provider as percentage (0-100). Shows pressure score, velocity (req/min), trend, ETA to limit, and recommendation (ok/caution/switch_now).',
|
|
107
|
+
inputSchema: {
|
|
108
|
+
type: 'object',
|
|
109
|
+
properties: {},
|
|
110
|
+
},
|
|
111
|
+
handler: async () => {
|
|
112
|
+
if (typeof lb.getQuota !== 'function') {
|
|
113
|
+
return { content: [{ type: 'text', text: 'lb_quota requires LoadBalancerLive (not stub). Upgrade mcp-server.' }] };
|
|
114
|
+
}
|
|
115
|
+
const quota = lb.getQuota();
|
|
116
|
+
const summaries = Object.values(quota).map(q => q.summary).join('\n\n');
|
|
117
|
+
return { content: [{ type: 'text', text: summaries + '\n\n' + JSON.stringify(quota, null, 2) }] };
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
module.exports = { createTools };
|