@blockrun/franklin 3.6.5 → 3.6.7
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/dist/agent/commands.js +134 -45
- package/dist/agent/context.js +67 -3
- package/dist/agent/loop.js +35 -4
- package/dist/agent/types.d.ts +2 -0
- package/dist/agent/verification.d.ts +42 -0
- package/dist/agent/verification.js +206 -0
- package/dist/commands/config.js +15 -7
- package/dist/commands/setup.js +7 -7
- package/dist/commands/start.js +4 -1
- package/dist/learnings/extractor.d.ts +5 -0
- package/dist/learnings/extractor.js +118 -2
- package/dist/learnings/index.d.ts +3 -3
- package/dist/learnings/index.js +2 -2
- package/dist/learnings/store.d.ts +11 -1
- package/dist/learnings/store.js +100 -0
- package/dist/learnings/types.d.ts +16 -0
- package/dist/tools/index.js +2 -0
- package/dist/tools/moa.d.ts +16 -0
- package/dist/tools/moa.js +173 -0
- package/dist/ui/app.js +7 -3
- package/package.json +1 -1
package/dist/commands/config.js
CHANGED
|
@@ -2,7 +2,8 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import chalk from 'chalk';
|
|
4
4
|
import { BLOCKRUN_DIR } from '../config.js';
|
|
5
|
-
const CONFIG_FILE = path.join(BLOCKRUN_DIR, '
|
|
5
|
+
const CONFIG_FILE = path.join(BLOCKRUN_DIR, 'franklin-config.json');
|
|
6
|
+
const LEGACY_CONFIG_FILE = path.join(BLOCKRUN_DIR, 'runcode-config.json');
|
|
6
7
|
const VALID_KEYS = [
|
|
7
8
|
'default-model',
|
|
8
9
|
'sonnet-model',
|
|
@@ -21,7 +22,14 @@ export function loadConfig() {
|
|
|
21
22
|
return JSON.parse(content);
|
|
22
23
|
}
|
|
23
24
|
catch {
|
|
24
|
-
|
|
25
|
+
// Fall back to legacy config file
|
|
26
|
+
try {
|
|
27
|
+
const legacy = fs.readFileSync(LEGACY_CONFIG_FILE, 'utf-8');
|
|
28
|
+
return JSON.parse(legacy);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return {};
|
|
32
|
+
}
|
|
25
33
|
}
|
|
26
34
|
}
|
|
27
35
|
function saveConfig(config) {
|
|
@@ -47,7 +55,7 @@ export function configCommand(action, keyOrUndefined, value) {
|
|
|
47
55
|
console.log(chalk.dim(`\nConfig file: ${CONFIG_FILE}`));
|
|
48
56
|
return;
|
|
49
57
|
}
|
|
50
|
-
console.log(chalk.bold('
|
|
58
|
+
console.log(chalk.bold('franklin config\n'));
|
|
51
59
|
for (const [k, v] of entries) {
|
|
52
60
|
console.log(` ${chalk.cyan(k)} = ${chalk.green(v)}`);
|
|
53
61
|
}
|
|
@@ -56,7 +64,7 @@ export function configCommand(action, keyOrUndefined, value) {
|
|
|
56
64
|
}
|
|
57
65
|
if (action === 'get') {
|
|
58
66
|
if (!keyOrUndefined) {
|
|
59
|
-
console.log(chalk.red('Usage:
|
|
67
|
+
console.log(chalk.red('Usage: franklin config get <key>'));
|
|
60
68
|
process.exit(1);
|
|
61
69
|
}
|
|
62
70
|
const config = loadConfig();
|
|
@@ -71,7 +79,7 @@ export function configCommand(action, keyOrUndefined, value) {
|
|
|
71
79
|
}
|
|
72
80
|
if (action === 'set') {
|
|
73
81
|
if (!keyOrUndefined || value === undefined) {
|
|
74
|
-
console.log(chalk.red('Usage:
|
|
82
|
+
console.log(chalk.red('Usage: franklin config set <key> <value>'));
|
|
75
83
|
process.exit(1);
|
|
76
84
|
}
|
|
77
85
|
if (!isValidKey(keyOrUndefined)) {
|
|
@@ -87,7 +95,7 @@ export function configCommand(action, keyOrUndefined, value) {
|
|
|
87
95
|
}
|
|
88
96
|
if (action === 'unset') {
|
|
89
97
|
if (!keyOrUndefined) {
|
|
90
|
-
console.log(chalk.red('Usage:
|
|
98
|
+
console.log(chalk.red('Usage: franklin config unset <key>'));
|
|
91
99
|
process.exit(1);
|
|
92
100
|
}
|
|
93
101
|
if (!isValidKey(keyOrUndefined)) {
|
|
@@ -102,6 +110,6 @@ export function configCommand(action, keyOrUndefined, value) {
|
|
|
102
110
|
return;
|
|
103
111
|
}
|
|
104
112
|
console.log(chalk.red(`Unknown action: ${action}`));
|
|
105
|
-
console.log('Usage:
|
|
113
|
+
console.log('Usage: franklin config <set|get|unset|list> [key] [value]');
|
|
106
114
|
process.exit(1);
|
|
107
115
|
}
|
package/dist/commands/setup.js
CHANGED
|
@@ -9,9 +9,9 @@ export async function setupCommand(chainArg) {
|
|
|
9
9
|
console.log(chalk.yellow('Solana wallet already exists.'));
|
|
10
10
|
console.log(`Address: ${chalk.cyan(wallets[0].publicKey)}`);
|
|
11
11
|
console.log(chalk.dim('\nNext steps:'));
|
|
12
|
-
console.log(chalk.dim('
|
|
13
|
-
console.log(chalk.dim('
|
|
14
|
-
console.log(chalk.dim('
|
|
12
|
+
console.log(chalk.dim(' franklin start — start coding'));
|
|
13
|
+
console.log(chalk.dim(' franklin balance — check USDC balance'));
|
|
14
|
+
console.log(chalk.dim(' franklin start -m free — use free models (no USDC needed)'));
|
|
15
15
|
saveChain('solana');
|
|
16
16
|
return;
|
|
17
17
|
}
|
|
@@ -29,9 +29,9 @@ export async function setupCommand(chainArg) {
|
|
|
29
29
|
console.log(chalk.yellow('Wallet already exists.'));
|
|
30
30
|
console.log(`Address: ${chalk.cyan(wallets[0].address)}`);
|
|
31
31
|
console.log(chalk.dim('\nNext steps:'));
|
|
32
|
-
console.log(chalk.dim('
|
|
33
|
-
console.log(chalk.dim('
|
|
34
|
-
console.log(chalk.dim('
|
|
32
|
+
console.log(chalk.dim(' franklin start — start coding'));
|
|
33
|
+
console.log(chalk.dim(' franklin balance — check USDC balance'));
|
|
34
|
+
console.log(chalk.dim(' franklin start -m free — use free models (no USDC needed)'));
|
|
35
35
|
saveChain('base');
|
|
36
36
|
return;
|
|
37
37
|
}
|
|
@@ -44,6 +44,6 @@ export async function setupCommand(chainArg) {
|
|
|
44
44
|
console.log(`\nSend USDC on Base to this address to fund your account.`);
|
|
45
45
|
}
|
|
46
46
|
saveChain(chain);
|
|
47
|
-
console.log(`Then run ${chalk.bold('
|
|
47
|
+
console.log(`Then run ${chalk.bold('franklin start')} to begin.\n`);
|
|
48
48
|
console.log(chalk.dim(`Chain: ${chain} — saved to ~/.blockrun/`));
|
|
49
49
|
}
|
package/dist/commands/start.js
CHANGED
|
@@ -130,8 +130,11 @@ export async function startCommand(options) {
|
|
|
130
130
|
}
|
|
131
131
|
}
|
|
132
132
|
}
|
|
133
|
-
// Build capabilities (built-in + MCP + sub-agent)
|
|
133
|
+
// Build capabilities (built-in + MCP + sub-agent + MoA)
|
|
134
134
|
const subAgent = createSubAgentCapability(apiUrl, chain, allCapabilities);
|
|
135
|
+
// Register MoA tool config (needs API URL for parallel model queries)
|
|
136
|
+
const { registerMoAConfig } = await import('../tools/moa.js');
|
|
137
|
+
registerMoAConfig(apiUrl, chain);
|
|
135
138
|
const capabilities = [...allCapabilities, ...mcpTools, subAgent];
|
|
136
139
|
// Validate tool descriptions (self-evolution: detect SearchX-style description bugs)
|
|
137
140
|
if (options.debug) {
|
|
@@ -14,6 +14,11 @@ export declare function bootstrapFromClaudeConfig(client: ModelClient): Promise<
|
|
|
14
14
|
* Runs asynchronously — caller should fire-and-forget.
|
|
15
15
|
*/
|
|
16
16
|
export declare function extractLearnings(history: Dialogue[], sessionId: string, client: ModelClient): Promise<void>;
|
|
17
|
+
/**
|
|
18
|
+
* Try to extract a reusable skill from the recent work.
|
|
19
|
+
* Called from maybeMidSessionExtract when enough tool calls happened.
|
|
20
|
+
*/
|
|
21
|
+
export declare function maybeExtractSkill(history: Dialogue[], turnToolCalls: number, sessionId: string, client: ModelClient): Promise<void>;
|
|
17
22
|
/**
|
|
18
23
|
* Check if mid-session extraction should run, and if so, run it in background.
|
|
19
24
|
* Called from the agent loop after tool execution completes.
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import os from 'node:os';
|
|
8
|
-
import { loadLearnings, mergeLearning, saveLearnings } from './store.js';
|
|
8
|
+
import { loadLearnings, mergeLearning, saveLearnings, loadSkills, saveSkill } from './store.js';
|
|
9
9
|
// Free models for learning extraction — JSON extraction is simple enough.
|
|
10
10
|
// Ordered by reliability: try the best free model first, fall back to others.
|
|
11
11
|
const EXTRACTION_MODELS = [
|
|
@@ -242,6 +242,120 @@ async function runExtraction(condensed, sessionId, client) {
|
|
|
242
242
|
}
|
|
243
243
|
saveLearnings(existing);
|
|
244
244
|
}
|
|
245
|
+
// ─── Skill extraction (procedural memory) ─────────────────────────────────
|
|
246
|
+
// After complex tasks, detect reusable procedures and save as skills.
|
|
247
|
+
const SKILL_EXTRACTION_PROMPT = `You are analyzing a conversation where an AI agent completed a complex multi-step task. Decide if this task pattern should be saved as a reusable skill (procedure).
|
|
248
|
+
|
|
249
|
+
Save a skill when:
|
|
250
|
+
1. The task involved 5+ distinct steps that could be repeated
|
|
251
|
+
2. The steps are generalizable (not one-off fixes for specific bugs)
|
|
252
|
+
3. Future similar tasks would benefit from having the procedure documented
|
|
253
|
+
|
|
254
|
+
If the task IS worth saving, output in this exact format (no markdown fences):
|
|
255
|
+
{"skill":{"name":"kebab-case-name","description":"One-line description","triggers":["keyword1","keyword2"],"steps":"## Steps\\n1. First step\\n2. Second step\\n..."}}
|
|
256
|
+
|
|
257
|
+
If NOT worth saving, output exactly:
|
|
258
|
+
{"skill":null}
|
|
259
|
+
|
|
260
|
+
Be selective — only save genuinely reusable multi-step procedures.`;
|
|
261
|
+
const MIN_TOOL_CALLS_FOR_SKILL = 5;
|
|
262
|
+
/**
|
|
263
|
+
* Try to extract a reusable skill from the recent work.
|
|
264
|
+
* Called from maybeMidSessionExtract when enough tool calls happened.
|
|
265
|
+
*/
|
|
266
|
+
export async function maybeExtractSkill(history, turnToolCalls, sessionId, client) {
|
|
267
|
+
if (turnToolCalls < MIN_TOOL_CALLS_FOR_SKILL)
|
|
268
|
+
return;
|
|
269
|
+
// Condense recent history with tool details (skills need tool context)
|
|
270
|
+
const parts = [];
|
|
271
|
+
let chars = 0;
|
|
272
|
+
const CAP = 6000;
|
|
273
|
+
for (const msg of history.slice(-20)) {
|
|
274
|
+
if (chars >= CAP)
|
|
275
|
+
break;
|
|
276
|
+
if (typeof msg.content === 'string') {
|
|
277
|
+
const line = `${msg.role}: ${msg.content.slice(0, 300)}`;
|
|
278
|
+
parts.push(line);
|
|
279
|
+
chars += line.length;
|
|
280
|
+
}
|
|
281
|
+
else if (Array.isArray(msg.content)) {
|
|
282
|
+
for (const p of msg.content) {
|
|
283
|
+
if (chars >= CAP)
|
|
284
|
+
break;
|
|
285
|
+
if (p.type === 'text') {
|
|
286
|
+
const line = `${msg.role}: ${p.text.slice(0, 200)}`;
|
|
287
|
+
parts.push(line);
|
|
288
|
+
chars += line.length;
|
|
289
|
+
}
|
|
290
|
+
else if (p.type === 'tool_use') {
|
|
291
|
+
const line = `tool: ${p.name}(${JSON.stringify(p.input).slice(0, 150)})`;
|
|
292
|
+
parts.push(line);
|
|
293
|
+
chars += line.length;
|
|
294
|
+
}
|
|
295
|
+
else if (p.type === 'tool_result') {
|
|
296
|
+
const text = typeof p.content === 'string' ? p.content : '';
|
|
297
|
+
const line = `result: ${text.slice(0, 100)}`;
|
|
298
|
+
parts.push(line);
|
|
299
|
+
chars += line.length;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
const condensed = parts.join('\n\n');
|
|
305
|
+
if (condensed.length < 200)
|
|
306
|
+
return;
|
|
307
|
+
try {
|
|
308
|
+
let text = '';
|
|
309
|
+
for (const model of EXTRACTION_MODELS) {
|
|
310
|
+
try {
|
|
311
|
+
const response = await client.complete({
|
|
312
|
+
model,
|
|
313
|
+
messages: [{ role: 'user', content: condensed }],
|
|
314
|
+
system: SKILL_EXTRACTION_PROMPT,
|
|
315
|
+
max_tokens: 1500,
|
|
316
|
+
temperature: 0.2,
|
|
317
|
+
});
|
|
318
|
+
text = response.content
|
|
319
|
+
.filter((p) => p.type === 'text')
|
|
320
|
+
.map((p) => p.text)
|
|
321
|
+
.join('');
|
|
322
|
+
break;
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
if (!text)
|
|
329
|
+
return;
|
|
330
|
+
// Parse JSON
|
|
331
|
+
const start = text.indexOf('{');
|
|
332
|
+
const end = text.lastIndexOf('}');
|
|
333
|
+
if (start === -1 || end === -1)
|
|
334
|
+
return;
|
|
335
|
+
const parsed = JSON.parse(text.slice(start, end + 1));
|
|
336
|
+
if (!parsed.skill)
|
|
337
|
+
return;
|
|
338
|
+
const { name, description, triggers, steps } = parsed.skill;
|
|
339
|
+
if (!name || !description || !steps)
|
|
340
|
+
return;
|
|
341
|
+
// Check for duplicate skills
|
|
342
|
+
const existing = loadSkills();
|
|
343
|
+
if (existing.some(s => s.name === name))
|
|
344
|
+
return;
|
|
345
|
+
saveSkill({
|
|
346
|
+
name,
|
|
347
|
+
description,
|
|
348
|
+
triggers: Array.isArray(triggers) ? triggers : [],
|
|
349
|
+
steps,
|
|
350
|
+
created: new Date().toISOString().split('T')[0],
|
|
351
|
+
uses: 0,
|
|
352
|
+
source_session: sessionId,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
catch {
|
|
356
|
+
// Skill extraction is best-effort
|
|
357
|
+
}
|
|
358
|
+
}
|
|
245
359
|
const midSessionState = {
|
|
246
360
|
lastExtractionTokens: 0,
|
|
247
361
|
lastExtractionToolCalls: 0,
|
|
@@ -289,7 +403,9 @@ export function maybeMidSessionExtract(history, estimatedTokens, totalToolCalls,
|
|
|
289
403
|
const condensed = condenseHistory(history);
|
|
290
404
|
if (condensed.length < 100)
|
|
291
405
|
return;
|
|
292
|
-
// Run in background — errors are silently swallowed
|
|
406
|
+
// Run learnings + skill extraction in background — errors are silently swallowed
|
|
293
407
|
runExtraction(condensed, `${sessionId}:mid-${midSessionState.extractionCount}`, client)
|
|
294
408
|
.catch(() => { });
|
|
409
|
+
maybeExtractSkill(history, totalToolCalls, sessionId, client)
|
|
410
|
+
.catch(() => { });
|
|
295
411
|
}
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export type { Learning, LearningCategory, ExtractionResult } from './types.js';
|
|
2
|
-
export { loadLearnings, saveLearnings, mergeLearning, decayLearnings, formatForPrompt } from './store.js';
|
|
3
|
-
export { extractLearnings, bootstrapFromClaudeConfig, maybeMidSessionExtract } from './extractor.js';
|
|
1
|
+
export type { Learning, LearningCategory, ExtractionResult, Skill } from './types.js';
|
|
2
|
+
export { loadLearnings, saveLearnings, mergeLearning, decayLearnings, formatForPrompt, loadSkills, saveSkill, matchSkills, formatSkillsForPrompt } from './store.js';
|
|
3
|
+
export { extractLearnings, bootstrapFromClaudeConfig, maybeMidSessionExtract, maybeExtractSkill } from './extractor.js';
|
package/dist/learnings/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { loadLearnings, saveLearnings, mergeLearning, decayLearnings, formatForPrompt } from './store.js';
|
|
2
|
-
export { extractLearnings, bootstrapFromClaudeConfig, maybeMidSessionExtract } from './extractor.js';
|
|
1
|
+
export { loadLearnings, saveLearnings, mergeLearning, decayLearnings, formatForPrompt, loadSkills, saveSkill, matchSkills, formatSkillsForPrompt } from './store.js';
|
|
2
|
+
export { extractLearnings, bootstrapFromClaudeConfig, maybeMidSessionExtract, maybeExtractSkill } from './extractor.js';
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Persistence layer for per-user learnings.
|
|
3
3
|
* Stored as JSONL at ~/.blockrun/learnings.jsonl.
|
|
4
4
|
*/
|
|
5
|
-
import type { Learning, LearningCategory } from './types.js';
|
|
5
|
+
import type { Learning, LearningCategory, Skill } from './types.js';
|
|
6
6
|
export declare function loadLearnings(): Learning[];
|
|
7
7
|
export declare function saveLearnings(learnings: Learning[]): void;
|
|
8
8
|
export declare function mergeLearning(existing: Learning[], newEntry: {
|
|
@@ -13,3 +13,13 @@ export declare function mergeLearning(existing: Learning[], newEntry: {
|
|
|
13
13
|
}): Learning[];
|
|
14
14
|
export declare function decayLearnings(learnings: Learning[]): Learning[];
|
|
15
15
|
export declare function formatForPrompt(learnings: Learning[]): string;
|
|
16
|
+
/** Load all skills from disk. */
|
|
17
|
+
export declare function loadSkills(): Skill[];
|
|
18
|
+
/** Save a new skill to disk. */
|
|
19
|
+
export declare function saveSkill(skill: Skill): void;
|
|
20
|
+
/** Bump use count for a skill. */
|
|
21
|
+
export declare function bumpSkillUse(skill: Skill): void;
|
|
22
|
+
/** Find skills relevant to a user message, by trigger matching. */
|
|
23
|
+
export declare function matchSkills(input: string, skills: Skill[]): Skill[];
|
|
24
|
+
/** Format matched skills for system prompt injection. */
|
|
25
|
+
export declare function formatSkillsForPrompt(skills: Skill[]): string;
|
package/dist/learnings/store.js
CHANGED
|
@@ -157,3 +157,103 @@ export function formatForPrompt(learnings) {
|
|
|
157
157
|
return '';
|
|
158
158
|
return '# Personal Context\nLearned from previous sessions:\n\n' + sections.join('\n\n');
|
|
159
159
|
}
|
|
160
|
+
// ─── Skills (procedural memory) ──────────────────────────────────────────
|
|
161
|
+
// Stored as individual markdown files in ~/.blockrun/skills/
|
|
162
|
+
// Larger than learnings, conditionally injected based on trigger matching.
|
|
163
|
+
const SKILLS_DIR = path.join(BLOCKRUN_DIR, 'skills');
|
|
164
|
+
const MAX_SKILLS_IN_PROMPT = 5;
|
|
165
|
+
const MAX_SKILL_CHARS = 1500;
|
|
166
|
+
function ensureSkillsDir() {
|
|
167
|
+
if (!fs.existsSync(SKILLS_DIR)) {
|
|
168
|
+
fs.mkdirSync(SKILLS_DIR, { recursive: true });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/** Load all skills from disk. */
|
|
172
|
+
export function loadSkills() {
|
|
173
|
+
ensureSkillsDir();
|
|
174
|
+
const skills = [];
|
|
175
|
+
try {
|
|
176
|
+
for (const file of fs.readdirSync(SKILLS_DIR).filter(f => f.endsWith('.md'))) {
|
|
177
|
+
try {
|
|
178
|
+
const raw = fs.readFileSync(path.join(SKILLS_DIR, file), 'utf-8');
|
|
179
|
+
const skill = parseSkillFile(raw);
|
|
180
|
+
if (skill)
|
|
181
|
+
skills.push(skill);
|
|
182
|
+
}
|
|
183
|
+
catch { /* skip corrupt */ }
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
catch { /* dir doesn't exist yet */ }
|
|
187
|
+
return skills;
|
|
188
|
+
}
|
|
189
|
+
function parseSkillFile(raw) {
|
|
190
|
+
const m = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
|
191
|
+
if (!m)
|
|
192
|
+
return null;
|
|
193
|
+
const fm = m[1];
|
|
194
|
+
const name = fm.match(/^name:\s*(.+)$/m)?.[1]?.trim() || '';
|
|
195
|
+
const description = fm.match(/^description:\s*(.+)$/m)?.[1]?.trim() || '';
|
|
196
|
+
const triggersRaw = fm.match(/^triggers:\s*\[([^\]]*)\]/m)?.[1] || '';
|
|
197
|
+
const triggers = triggersRaw.split(',').map(t => t.trim()).filter(Boolean);
|
|
198
|
+
const created = fm.match(/^created:\s*(.+)$/m)?.[1]?.trim() || '';
|
|
199
|
+
const uses = parseInt(fm.match(/^uses:\s*(\d+)$/m)?.[1] || '0');
|
|
200
|
+
const source = fm.match(/^source_session:\s*(.+)$/m)?.[1]?.trim() || '';
|
|
201
|
+
if (!name)
|
|
202
|
+
return null;
|
|
203
|
+
return { name, description, triggers, steps: m[2].trim(), created, uses, source_session: source };
|
|
204
|
+
}
|
|
205
|
+
/** Save a new skill to disk. */
|
|
206
|
+
export function saveSkill(skill) {
|
|
207
|
+
ensureSkillsDir();
|
|
208
|
+
const filename = skill.name.replace(/[^a-z0-9-]/gi, '-').toLowerCase() + '.md';
|
|
209
|
+
const fm = [
|
|
210
|
+
'---',
|
|
211
|
+
`name: ${skill.name}`,
|
|
212
|
+
`description: ${skill.description}`,
|
|
213
|
+
`triggers: [${skill.triggers.join(', ')}]`,
|
|
214
|
+
`created: ${skill.created}`,
|
|
215
|
+
`uses: ${skill.uses}`,
|
|
216
|
+
`source_session: ${skill.source_session}`,
|
|
217
|
+
'---',
|
|
218
|
+
].join('\n');
|
|
219
|
+
fs.writeFileSync(path.join(SKILLS_DIR, filename), `${fm}\n${skill.steps}\n`);
|
|
220
|
+
}
|
|
221
|
+
/** Bump use count for a skill. */
|
|
222
|
+
export function bumpSkillUse(skill) {
|
|
223
|
+
const filename = skill.name.replace(/[^a-z0-9-]/gi, '-').toLowerCase() + '.md';
|
|
224
|
+
const fp = path.join(SKILLS_DIR, filename);
|
|
225
|
+
try {
|
|
226
|
+
const raw = fs.readFileSync(fp, 'utf-8');
|
|
227
|
+
fs.writeFileSync(fp, raw.replace(/^uses:\s*\d+$/m, `uses: ${skill.uses + 1}`));
|
|
228
|
+
}
|
|
229
|
+
catch { /* non-critical */ }
|
|
230
|
+
}
|
|
231
|
+
/** Find skills relevant to a user message, by trigger matching. */
|
|
232
|
+
export function matchSkills(input, skills) {
|
|
233
|
+
const lower = input.toLowerCase();
|
|
234
|
+
const scored = [];
|
|
235
|
+
for (const s of skills) {
|
|
236
|
+
let score = 0;
|
|
237
|
+
for (const t of s.triggers) {
|
|
238
|
+
if (lower.includes(t.toLowerCase()))
|
|
239
|
+
score += 2;
|
|
240
|
+
}
|
|
241
|
+
if (lower.includes(s.name.toLowerCase()))
|
|
242
|
+
score += 3;
|
|
243
|
+
score += Math.min(s.uses * 0.5, 3);
|
|
244
|
+
if (score > 0)
|
|
245
|
+
scored.push({ skill: s, score });
|
|
246
|
+
}
|
|
247
|
+
return scored.sort((a, b) => b.score - a.score).slice(0, MAX_SKILLS_IN_PROMPT).map(m => m.skill);
|
|
248
|
+
}
|
|
249
|
+
/** Format matched skills for system prompt injection. */
|
|
250
|
+
export function formatSkillsForPrompt(skills) {
|
|
251
|
+
if (skills.length === 0)
|
|
252
|
+
return '';
|
|
253
|
+
const parts = ['# Learned Skills\nProcedures from previous experience — use when relevant:\n'];
|
|
254
|
+
for (const s of skills) {
|
|
255
|
+
const body = s.steps.length > MAX_SKILL_CHARS ? s.steps.slice(0, MAX_SKILL_CHARS) + '\n…' : s.steps;
|
|
256
|
+
parts.push(`## ${s.name}\n*${s.description}*\n\n${body}`);
|
|
257
|
+
}
|
|
258
|
+
return parts.join('\n\n');
|
|
259
|
+
}
|
|
@@ -21,4 +21,20 @@ export interface ExtractionResult {
|
|
|
21
21
|
category: LearningCategory;
|
|
22
22
|
confidence: number;
|
|
23
23
|
}>;
|
|
24
|
+
/** Procedural skills extracted from complex task patterns. */
|
|
25
|
+
skills?: Array<{
|
|
26
|
+
name: string;
|
|
27
|
+
description: string;
|
|
28
|
+
triggers: string[];
|
|
29
|
+
steps: string;
|
|
30
|
+
}>;
|
|
31
|
+
}
|
|
32
|
+
export interface Skill {
|
|
33
|
+
name: string;
|
|
34
|
+
description: string;
|
|
35
|
+
triggers: string[];
|
|
36
|
+
steps: string;
|
|
37
|
+
created: string;
|
|
38
|
+
uses: number;
|
|
39
|
+
source_session: string;
|
|
24
40
|
}
|
package/dist/tools/index.js
CHANGED
|
@@ -15,6 +15,7 @@ import { askUserCapability } from './askuser.js';
|
|
|
15
15
|
import { tradingSignalCapability, tradingMarketCapability } from './trading.js';
|
|
16
16
|
import { searchXCapability } from './searchx.js';
|
|
17
17
|
import { postToXCapability } from './posttox.js';
|
|
18
|
+
import { moaCapability } from './moa.js';
|
|
18
19
|
/** All capabilities available to the Franklin agent (excluding sub-agent, which needs config). */
|
|
19
20
|
export const allCapabilities = [
|
|
20
21
|
readCapability,
|
|
@@ -32,6 +33,7 @@ export const allCapabilities = [
|
|
|
32
33
|
tradingMarketCapability,
|
|
33
34
|
searchXCapability,
|
|
34
35
|
postToXCapability,
|
|
36
|
+
moaCapability,
|
|
35
37
|
];
|
|
36
38
|
export { readCapability, writeCapability, editCapability, bashCapability, globCapability, grepCapability, webFetchCapability, webSearchCapability, taskCapability, };
|
|
37
39
|
export { createSubAgentCapability } from './subagent.js';
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mixture-of-Agents (MoA) — query multiple models in parallel, aggregate with a strong model.
|
|
3
|
+
*
|
|
4
|
+
* How it works:
|
|
5
|
+
* 1. Send the same prompt to N reference models (cheap/free) in parallel
|
|
6
|
+
* 2. Collect all responses
|
|
7
|
+
* 3. Send all responses + the original prompt to a strong aggregator model
|
|
8
|
+
* 4. Aggregator synthesizes the best answer from all references
|
|
9
|
+
*
|
|
10
|
+
* This produces higher-quality answers than any single model for complex questions.
|
|
11
|
+
* Inspired by the Mixture-of-Agents architecture from Together.ai research.
|
|
12
|
+
*/
|
|
13
|
+
import type { CapabilityHandler } from '../agent/types.js';
|
|
14
|
+
export declare const moaCapability: CapabilityHandler;
|
|
15
|
+
/** Register the API URL for MoA tool (called during agent setup). */
|
|
16
|
+
export declare function registerMoAConfig(apiUrl: string, chain: 'base' | 'solana'): void;
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mixture-of-Agents (MoA) — query multiple models in parallel, aggregate with a strong model.
|
|
3
|
+
*
|
|
4
|
+
* How it works:
|
|
5
|
+
* 1. Send the same prompt to N reference models (cheap/free) in parallel
|
|
6
|
+
* 2. Collect all responses
|
|
7
|
+
* 3. Send all responses + the original prompt to a strong aggregator model
|
|
8
|
+
* 4. Aggregator synthesizes the best answer from all references
|
|
9
|
+
*
|
|
10
|
+
* This produces higher-quality answers than any single model for complex questions.
|
|
11
|
+
* Inspired by the Mixture-of-Agents architecture from Together.ai research.
|
|
12
|
+
*/
|
|
13
|
+
import { ModelClient } from '../agent/llm.js';
|
|
14
|
+
// ─── Configuration ────────────────────────────────────────────────────────
|
|
15
|
+
/** Reference models — diverse, cheap/free models for parallel queries. */
|
|
16
|
+
const REFERENCE_MODELS = [
|
|
17
|
+
'nvidia/nemotron-ultra-253b', // Free, strong reasoning
|
|
18
|
+
'nvidia/qwen3-coder-480b', // Free, strong coding
|
|
19
|
+
'google/gemini-2.5-flash', // Fast, cheap
|
|
20
|
+
'deepseek/deepseek-chat', // Cheap, good reasoning
|
|
21
|
+
];
|
|
22
|
+
/** Aggregator model — strong model that synthesizes the best answer. */
|
|
23
|
+
const AGGREGATOR_MODEL = 'anthropic/claude-sonnet-4.6';
|
|
24
|
+
/** Max tokens per reference response. */
|
|
25
|
+
const REFERENCE_MAX_TOKENS = 4096;
|
|
26
|
+
/** Max tokens for aggregator. */
|
|
27
|
+
const AGGREGATOR_MAX_TOKENS = 8192;
|
|
28
|
+
/** Timeout per reference model call (ms). */
|
|
29
|
+
const REFERENCE_TIMEOUT_MS = 60_000;
|
|
30
|
+
// ─── Implementation ──────────────────────────────────────────────────────
|
|
31
|
+
// These will be injected at registration time
|
|
32
|
+
let registeredApiUrl = '';
|
|
33
|
+
let registeredChain = 'base';
|
|
34
|
+
async function execute(input, ctx) {
|
|
35
|
+
const { prompt, models, aggregator, include_reasoning } = input;
|
|
36
|
+
if (!prompt) {
|
|
37
|
+
return { output: 'Error: prompt is required', isError: true };
|
|
38
|
+
}
|
|
39
|
+
const referenceModels = models || REFERENCE_MODELS;
|
|
40
|
+
const aggregatorModel = aggregator || AGGREGATOR_MODEL;
|
|
41
|
+
const client = new ModelClient({
|
|
42
|
+
apiUrl: registeredApiUrl,
|
|
43
|
+
chain: registeredChain,
|
|
44
|
+
});
|
|
45
|
+
ctx.onProgress?.('Querying reference models...');
|
|
46
|
+
// Step 1: Query all reference models in parallel
|
|
47
|
+
const referencePromises = referenceModels.map(async (model) => {
|
|
48
|
+
const controller = new AbortController();
|
|
49
|
+
const timer = setTimeout(() => controller.abort(), REFERENCE_TIMEOUT_MS);
|
|
50
|
+
try {
|
|
51
|
+
const response = await client.complete({
|
|
52
|
+
model,
|
|
53
|
+
messages: [{ role: 'user', content: prompt }],
|
|
54
|
+
max_tokens: REFERENCE_MAX_TOKENS,
|
|
55
|
+
stream: false,
|
|
56
|
+
}, controller.signal);
|
|
57
|
+
clearTimeout(timer);
|
|
58
|
+
// Extract text from response
|
|
59
|
+
let text = '';
|
|
60
|
+
if (response.content) {
|
|
61
|
+
for (const part of response.content) {
|
|
62
|
+
if (typeof part === 'string')
|
|
63
|
+
text += part;
|
|
64
|
+
else if (part.type === 'text')
|
|
65
|
+
text += part.text;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return { model, text: text.trim(), error: null };
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
clearTimeout(timer);
|
|
72
|
+
return { model, text: '', error: err.message };
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
const references = await Promise.all(referencePromises);
|
|
76
|
+
// Filter out failures
|
|
77
|
+
const successRefs = references.filter(r => r.text && !r.error);
|
|
78
|
+
if (successRefs.length === 0) {
|
|
79
|
+
const errors = references.map(r => `${r.model}: ${r.error}`).join('\n');
|
|
80
|
+
return { output: `All reference models failed:\n${errors}`, isError: true };
|
|
81
|
+
}
|
|
82
|
+
ctx.onProgress?.(`${successRefs.length}/${referenceModels.length} responded, aggregating...`);
|
|
83
|
+
// Step 2: Build aggregation prompt
|
|
84
|
+
const refSection = successRefs.map((r, i) => `## Response ${i + 1} (${r.model})\n\n${r.text}`).join('\n\n---\n\n');
|
|
85
|
+
const aggregationPrompt = `You have been given ${successRefs.length} responses to the same question from different AI models. Your job is to synthesize the BEST possible answer by:
|
|
86
|
+
|
|
87
|
+
1. Identifying the strongest insights from each response
|
|
88
|
+
2. Resolving any contradictions (prefer verifiable facts)
|
|
89
|
+
3. Combining the best parts into a single, coherent answer
|
|
90
|
+
4. Adding any important points that ALL models missed
|
|
91
|
+
|
|
92
|
+
## Original Question
|
|
93
|
+
|
|
94
|
+
${prompt}
|
|
95
|
+
|
|
96
|
+
## Reference Responses
|
|
97
|
+
|
|
98
|
+
${refSection}
|
|
99
|
+
|
|
100
|
+
## Your Task
|
|
101
|
+
|
|
102
|
+
Synthesize the best possible answer. Be comprehensive but concise. If the responses agree, be confident. If they disagree, note the disagreement and explain which is more likely correct.`;
|
|
103
|
+
// Step 3: Aggregate with strong model
|
|
104
|
+
try {
|
|
105
|
+
const aggResponse = await client.complete({
|
|
106
|
+
model: aggregatorModel,
|
|
107
|
+
messages: [{ role: 'user', content: aggregationPrompt }],
|
|
108
|
+
max_tokens: AGGREGATOR_MAX_TOKENS,
|
|
109
|
+
stream: false,
|
|
110
|
+
}, ctx.abortSignal);
|
|
111
|
+
let aggText = '';
|
|
112
|
+
if (aggResponse.content) {
|
|
113
|
+
for (const part of aggResponse.content) {
|
|
114
|
+
if (typeof part === 'string')
|
|
115
|
+
aggText += part;
|
|
116
|
+
else if (part.type === 'text')
|
|
117
|
+
aggText += part.text;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// Build output
|
|
121
|
+
const parts = [];
|
|
122
|
+
parts.push(aggText.trim());
|
|
123
|
+
if (include_reasoning) {
|
|
124
|
+
parts.push('\n\n---\n*Reference responses:*');
|
|
125
|
+
for (const ref of successRefs) {
|
|
126
|
+
parts.push(`\n**${ref.model}:** ${ref.text.slice(0, 500)}${ref.text.length > 500 ? '...' : ''}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// Note which models responded
|
|
130
|
+
const modelList = successRefs.map(r => r.model.split('/').pop()).join(', ');
|
|
131
|
+
const failList = references.filter(r => r.error).map(r => r.model.split('/').pop()).join(', ');
|
|
132
|
+
parts.push(`\n\n*MoA: ${successRefs.length} models (${modelList})${failList ? `, ${failList} failed` : ''} → ${aggregatorModel.split('/').pop()}*`);
|
|
133
|
+
return { output: parts.join('\n') };
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
return {
|
|
137
|
+
output: `Aggregation failed: ${err.message}\n\nBest reference response (${successRefs[0].model}):\n${successRefs[0].text}`,
|
|
138
|
+
isError: true,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
export const moaCapability = {
|
|
143
|
+
spec: {
|
|
144
|
+
name: 'MixtureOfAgents',
|
|
145
|
+
description: `Query multiple AI models in parallel and synthesize the best answer.
|
|
146
|
+
|
|
147
|
+
Use this for complex questions where a single model might miss important perspectives.
|
|
148
|
+
Sends the prompt to 4 diverse models, then aggregates with a strong model.
|
|
149
|
+
|
|
150
|
+
Parameters:
|
|
151
|
+
- prompt (required): The question or task to send to all models
|
|
152
|
+
- models (optional): Array of model IDs to use as references (default: 4 diverse free/cheap models)
|
|
153
|
+
- aggregator (optional): Model to aggregate responses (default: claude-sonnet-4.6)
|
|
154
|
+
- include_reasoning (optional): If true, include reference responses in output`,
|
|
155
|
+
input_schema: {
|
|
156
|
+
type: 'object',
|
|
157
|
+
required: ['prompt'],
|
|
158
|
+
properties: {
|
|
159
|
+
prompt: { type: 'string', description: 'The question or task to send to all models' },
|
|
160
|
+
models: { type: 'array', items: { type: 'string' }, description: 'Override reference models' },
|
|
161
|
+
aggregator: { type: 'string', description: 'Override aggregator model' },
|
|
162
|
+
include_reasoning: { type: 'boolean', description: 'Include reference responses in output' },
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
execute,
|
|
167
|
+
concurrent: true,
|
|
168
|
+
};
|
|
169
|
+
/** Register the API URL for MoA tool (called during agent setup). */
|
|
170
|
+
export function registerMoAConfig(apiUrl, chain) {
|
|
171
|
+
registeredApiUrl = apiUrl;
|
|
172
|
+
registeredChain = chain;
|
|
173
|
+
}
|