@blockrun/franklin 3.0.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/LICENSE +190 -0
- package/README.md +256 -0
- package/dist/agent/commands.d.ts +27 -0
- package/dist/agent/commands.js +659 -0
- package/dist/agent/compact.d.ts +31 -0
- package/dist/agent/compact.js +366 -0
- package/dist/agent/context.d.ts +11 -0
- package/dist/agent/context.js +184 -0
- package/dist/agent/error-classifier.d.ts +10 -0
- package/dist/agent/error-classifier.js +61 -0
- package/dist/agent/llm.d.ts +63 -0
- package/dist/agent/llm.js +448 -0
- package/dist/agent/loop.d.ts +12 -0
- package/dist/agent/loop.js +346 -0
- package/dist/agent/optimize.d.ts +53 -0
- package/dist/agent/optimize.js +262 -0
- package/dist/agent/permissions.d.ts +39 -0
- package/dist/agent/permissions.js +226 -0
- package/dist/agent/reduce.d.ts +49 -0
- package/dist/agent/reduce.js +317 -0
- package/dist/agent/streaming-executor.d.ts +36 -0
- package/dist/agent/streaming-executor.js +149 -0
- package/dist/agent/tokens.d.ts +53 -0
- package/dist/agent/tokens.js +185 -0
- package/dist/agent/types.d.ts +125 -0
- package/dist/agent/types.js +5 -0
- package/dist/banner.d.ts +1 -0
- package/dist/banner.js +27 -0
- package/dist/commands/balance.d.ts +1 -0
- package/dist/commands/balance.js +40 -0
- package/dist/commands/config.d.ts +14 -0
- package/dist/commands/config.js +107 -0
- package/dist/commands/daemon.d.ts +3 -0
- package/dist/commands/daemon.js +117 -0
- package/dist/commands/history.d.ts +5 -0
- package/dist/commands/history.js +31 -0
- package/dist/commands/init.d.ts +3 -0
- package/dist/commands/init.js +92 -0
- package/dist/commands/logs.d.ts +5 -0
- package/dist/commands/logs.js +89 -0
- package/dist/commands/models.d.ts +1 -0
- package/dist/commands/models.js +56 -0
- package/dist/commands/plugin.d.ts +14 -0
- package/dist/commands/plugin.js +176 -0
- package/dist/commands/proxy.d.ts +13 -0
- package/dist/commands/proxy.js +106 -0
- package/dist/commands/setup.d.ts +1 -0
- package/dist/commands/setup.js +49 -0
- package/dist/commands/start.d.ts +8 -0
- package/dist/commands/start.js +292 -0
- package/dist/commands/stats.d.ts +10 -0
- package/dist/commands/stats.js +94 -0
- package/dist/commands/uninit.d.ts +1 -0
- package/dist/commands/uninit.js +63 -0
- package/dist/config.d.ts +9 -0
- package/dist/config.js +41 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +179 -0
- package/dist/mcp/client.d.ts +44 -0
- package/dist/mcp/client.js +147 -0
- package/dist/mcp/config.d.ts +20 -0
- package/dist/mcp/config.js +138 -0
- package/dist/plugin-sdk/channel.d.ts +100 -0
- package/dist/plugin-sdk/channel.js +10 -0
- package/dist/plugin-sdk/index.d.ts +14 -0
- package/dist/plugin-sdk/index.js +9 -0
- package/dist/plugin-sdk/plugin.d.ts +87 -0
- package/dist/plugin-sdk/plugin.js +7 -0
- package/dist/plugin-sdk/search.d.ts +13 -0
- package/dist/plugin-sdk/search.js +4 -0
- package/dist/plugin-sdk/tracker.d.ts +27 -0
- package/dist/plugin-sdk/tracker.js +5 -0
- package/dist/plugin-sdk/workflow.d.ts +126 -0
- package/dist/plugin-sdk/workflow.js +11 -0
- package/dist/plugins/registry.d.ts +33 -0
- package/dist/plugins/registry.js +155 -0
- package/dist/plugins/runner.d.ts +21 -0
- package/dist/plugins/runner.js +453 -0
- package/dist/plugins-bundled/social/index.d.ts +10 -0
- package/dist/plugins-bundled/social/index.js +363 -0
- package/dist/plugins-bundled/social/plugin.json +14 -0
- package/dist/plugins-bundled/social/prompts.d.ts +19 -0
- package/dist/plugins-bundled/social/prompts.js +67 -0
- package/dist/plugins-bundled/social/types.d.ts +58 -0
- package/dist/plugins-bundled/social/types.js +16 -0
- package/dist/pricing.d.ts +21 -0
- package/dist/pricing.js +91 -0
- package/dist/proxy/fallback.d.ts +38 -0
- package/dist/proxy/fallback.js +144 -0
- package/dist/proxy/server.d.ts +18 -0
- package/dist/proxy/server.js +576 -0
- package/dist/proxy/sse-translator.d.ts +29 -0
- package/dist/proxy/sse-translator.js +270 -0
- package/dist/router/index.d.ts +22 -0
- package/dist/router/index.js +269 -0
- package/dist/session/search.d.ts +33 -0
- package/dist/session/search.js +229 -0
- package/dist/session/storage.d.ts +48 -0
- package/dist/session/storage.js +173 -0
- package/dist/stats/insights.d.ts +55 -0
- package/dist/stats/insights.js +195 -0
- package/dist/stats/tracker.d.ts +54 -0
- package/dist/stats/tracker.js +165 -0
- package/dist/tools/askuser.d.ts +6 -0
- package/dist/tools/askuser.js +76 -0
- package/dist/tools/bash.d.ts +5 -0
- package/dist/tools/bash.js +336 -0
- package/dist/tools/edit.d.ts +5 -0
- package/dist/tools/edit.js +148 -0
- package/dist/tools/glob.d.ts +5 -0
- package/dist/tools/glob.js +158 -0
- package/dist/tools/grep.d.ts +5 -0
- package/dist/tools/grep.js +194 -0
- package/dist/tools/imagegen.d.ts +6 -0
- package/dist/tools/imagegen.js +172 -0
- package/dist/tools/index.d.ts +17 -0
- package/dist/tools/index.js +30 -0
- package/dist/tools/read.d.ts +11 -0
- package/dist/tools/read.js +90 -0
- package/dist/tools/subagent.d.ts +5 -0
- package/dist/tools/subagent.js +116 -0
- package/dist/tools/task.d.ts +5 -0
- package/dist/tools/task.js +91 -0
- package/dist/tools/webfetch.d.ts +5 -0
- package/dist/tools/webfetch.js +166 -0
- package/dist/tools/websearch.d.ts +5 -0
- package/dist/tools/websearch.js +103 -0
- package/dist/tools/write.d.ts +5 -0
- package/dist/tools/write.js +114 -0
- package/dist/ui/app.d.ts +26 -0
- package/dist/ui/app.js +545 -0
- package/dist/ui/model-picker.d.ts +14 -0
- package/dist/ui/model-picker.js +161 -0
- package/dist/ui/terminal.d.ts +35 -0
- package/dist/ui/terminal.js +337 -0
- package/dist/wallet/manager.d.ts +10 -0
- package/dist/wallet/manager.js +23 -0
- package/package.json +79 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin Registry — discovers, loads, and manages plugins.
|
|
3
|
+
*
|
|
4
|
+
* Core stays plugin-agnostic: it knows about the *interface*, not specific plugins.
|
|
5
|
+
* Plugins are discovered from:
|
|
6
|
+
* 1. Bundled: <runcode>/plugins-bundled/* (ships with runcode)
|
|
7
|
+
* 2. User: ~/.blockrun/plugins/* (installed via `runcode plugin install`)
|
|
8
|
+
* 3. Local dev: $RUNCODE_PLUGINS_DIR/* (env var for development)
|
|
9
|
+
*/
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import os from 'node:os';
|
|
14
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
// ─── Plugin Discovery Paths ───────────────────────────────────────────────
|
|
16
|
+
export function getBundledPluginsDir() {
|
|
17
|
+
// From dist/plugins/registry.js, plugins-bundled is at ../plugins-bundled
|
|
18
|
+
// (built from src/plugins-bundled by tsc + copy-plugin-assets)
|
|
19
|
+
return path.resolve(__dirname, '..', 'plugins-bundled');
|
|
20
|
+
}
|
|
21
|
+
export function getUserPluginsDir() {
|
|
22
|
+
return path.join(os.homedir(), '.blockrun', 'plugins');
|
|
23
|
+
}
|
|
24
|
+
function getDevPluginsDir() {
|
|
25
|
+
return process.env.RUNCODE_PLUGINS_DIR || null;
|
|
26
|
+
}
|
|
27
|
+
const loaded = new Map();
|
|
28
|
+
// ─── Discovery ────────────────────────────────────────────────────────────
|
|
29
|
+
/** Find all plugin manifests across discovery paths */
|
|
30
|
+
export function discoverPluginManifests() {
|
|
31
|
+
const found = [];
|
|
32
|
+
const seen = new Set();
|
|
33
|
+
const searchPaths = [];
|
|
34
|
+
const dev = getDevPluginsDir();
|
|
35
|
+
if (dev && fs.existsSync(dev))
|
|
36
|
+
searchPaths.push(dev);
|
|
37
|
+
const user = getUserPluginsDir();
|
|
38
|
+
if (fs.existsSync(user))
|
|
39
|
+
searchPaths.push(user);
|
|
40
|
+
const bundled = getBundledPluginsDir();
|
|
41
|
+
if (fs.existsSync(bundled))
|
|
42
|
+
searchPaths.push(bundled);
|
|
43
|
+
for (const base of searchPaths) {
|
|
44
|
+
let entries = [];
|
|
45
|
+
try {
|
|
46
|
+
entries = fs.readdirSync(base);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
for (const entry of entries) {
|
|
52
|
+
const pluginDir = path.join(base, entry);
|
|
53
|
+
const manifestPath = path.join(pluginDir, 'plugin.json');
|
|
54
|
+
if (!fs.existsSync(manifestPath))
|
|
55
|
+
continue;
|
|
56
|
+
try {
|
|
57
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
|
58
|
+
if (!manifest.id || seen.has(manifest.id))
|
|
59
|
+
continue;
|
|
60
|
+
seen.add(manifest.id);
|
|
61
|
+
found.push({ manifest, dir: pluginDir });
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// Invalid manifest — skip
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return found;
|
|
69
|
+
}
|
|
70
|
+
// ─── Loading ──────────────────────────────────────────────────────────────
|
|
71
|
+
/** Load a single plugin from its directory */
|
|
72
|
+
export async function loadPlugin(manifest, pluginDir) {
|
|
73
|
+
// Resolve entry path. Plugin's entry should point to a built JS file.
|
|
74
|
+
// For bundled plugins, entry might be "dist/index.js" but we ship from src/.
|
|
75
|
+
// Check both — prefer dist if present.
|
|
76
|
+
let entryPath = path.join(pluginDir, manifest.entry);
|
|
77
|
+
if (!fs.existsSync(entryPath)) {
|
|
78
|
+
// Try .js extension swap (TS source vs built)
|
|
79
|
+
const jsEntry = entryPath.replace(/\.ts$/, '.js');
|
|
80
|
+
if (fs.existsSync(jsEntry))
|
|
81
|
+
entryPath = jsEntry;
|
|
82
|
+
}
|
|
83
|
+
if (!fs.existsSync(entryPath)) {
|
|
84
|
+
process.stderr.write(`[plugin:${manifest.id}] entry not found: ${manifest.entry}\n`);
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
// Dynamic import — works for both ESM and CJS
|
|
89
|
+
const mod = await import(entryPath);
|
|
90
|
+
const plugin = mod.default ?? mod.plugin ?? mod;
|
|
91
|
+
if (!plugin || typeof plugin !== 'object') {
|
|
92
|
+
process.stderr.write(`[plugin:${manifest.id}] invalid plugin export\n`);
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
// Inject manifest if plugin didn't include it
|
|
96
|
+
plugin.manifest = manifest;
|
|
97
|
+
return plugin;
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
process.stderr.write(`[plugin:${manifest.id}] load failed: ${err.message}\n`);
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/** Discover and load all plugins. Returns the loaded registry. */
|
|
105
|
+
export async function loadAllPlugins() {
|
|
106
|
+
if (loaded.size > 0)
|
|
107
|
+
return loaded;
|
|
108
|
+
const manifests = discoverPluginManifests();
|
|
109
|
+
for (const { manifest, dir } of manifests) {
|
|
110
|
+
const plugin = await loadPlugin(manifest, dir);
|
|
111
|
+
if (plugin) {
|
|
112
|
+
loaded.set(manifest.id, { manifest, pluginDir: dir, plugin });
|
|
113
|
+
// Lifecycle hook
|
|
114
|
+
if (plugin.onLoad) {
|
|
115
|
+
try {
|
|
116
|
+
await plugin.onLoad({
|
|
117
|
+
runcodeVersion: getRuncodeVersion(),
|
|
118
|
+
dataDir: path.join(os.homedir(), '.blockrun', 'plugins', manifest.id),
|
|
119
|
+
pluginDir: dir,
|
|
120
|
+
log: (msg) => process.stderr.write(`[${manifest.id}] ${msg}\n`),
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
process.stderr.write(`[plugin:${manifest.id}] onLoad failed: ${err.message}\n`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return loaded;
|
|
130
|
+
}
|
|
131
|
+
// ─── Query API ────────────────────────────────────────────────────────────
|
|
132
|
+
export function getPlugin(id) {
|
|
133
|
+
return loaded.get(id);
|
|
134
|
+
}
|
|
135
|
+
export function listPlugins() {
|
|
136
|
+
return Array.from(loaded.values());
|
|
137
|
+
}
|
|
138
|
+
/** Get all plugins that provide workflows */
|
|
139
|
+
export function listWorkflowPlugins() {
|
|
140
|
+
return listPlugins().filter(p => p.plugin.workflows && Object.keys(p.plugin.workflows).length > 0);
|
|
141
|
+
}
|
|
142
|
+
/** Get all plugins that provide channels */
|
|
143
|
+
export function listChannelPlugins() {
|
|
144
|
+
return listPlugins().filter(p => p.plugin.channels && Object.keys(p.plugin.channels).length > 0);
|
|
145
|
+
}
|
|
146
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────
|
|
147
|
+
function getRuncodeVersion() {
|
|
148
|
+
try {
|
|
149
|
+
const pkgPath = path.resolve(__dirname, '..', '..', 'package.json');
|
|
150
|
+
return JSON.parse(fs.readFileSync(pkgPath, 'utf-8')).version || '0.0.0';
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return '0.0.0';
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow Runner — orchestrates execution of any Workflow.
|
|
3
|
+
*
|
|
4
|
+
* Plugin-agnostic: takes a Workflow + config, runs steps, handles
|
|
5
|
+
* model dispatch, dedup, tracking, dry-run.
|
|
6
|
+
*/
|
|
7
|
+
import { ModelClient } from '../agent/llm.js';
|
|
8
|
+
import type { Workflow, WorkflowConfig, WorkflowResult } from '../plugin-sdk/workflow.js';
|
|
9
|
+
import type { WorkflowStats, TrackedAction } from '../plugin-sdk/tracker.js';
|
|
10
|
+
export declare function loadWorkflowConfig(workflowId: string): WorkflowConfig | null;
|
|
11
|
+
export declare function saveWorkflowConfig(workflowId: string, config: WorkflowConfig): void;
|
|
12
|
+
interface TrackerEntry extends TrackedAction {
|
|
13
|
+
}
|
|
14
|
+
export declare function getStats(workflow: string): WorkflowStats;
|
|
15
|
+
export declare function getByAction(workflow: string, action: string): TrackerEntry[];
|
|
16
|
+
export declare function runWorkflow(workflow: Workflow, config: WorkflowConfig, client: ModelClient, options?: {
|
|
17
|
+
dryRun?: boolean;
|
|
18
|
+
}): Promise<WorkflowResult>;
|
|
19
|
+
export declare function formatWorkflowResult(workflow: Workflow, result: WorkflowResult): string;
|
|
20
|
+
export declare function formatWorkflowStats(workflow: Workflow, stats: WorkflowStats): string;
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow Runner — orchestrates execution of any Workflow.
|
|
3
|
+
*
|
|
4
|
+
* Plugin-agnostic: takes a Workflow + config, runs steps, handles
|
|
5
|
+
* model dispatch, dedup, tracking, dry-run.
|
|
6
|
+
*/
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import { estimateCost } from '../pricing.js';
|
|
11
|
+
import { USER_AGENT } from '../config.js';
|
|
12
|
+
import { DEFAULT_MODEL_TIERS } from '../plugin-sdk/workflow.js';
|
|
13
|
+
// ─── Storage ──────────────────────────────────────────────────────────────
|
|
14
|
+
const WORKFLOW_DIR = path.join(os.homedir(), '.blockrun', 'workflows');
|
|
15
|
+
function ensureDir() {
|
|
16
|
+
fs.mkdirSync(WORKFLOW_DIR, { recursive: true });
|
|
17
|
+
}
|
|
18
|
+
function getDbPath(workflow) {
|
|
19
|
+
return path.join(WORKFLOW_DIR, `${workflow}.jsonl`);
|
|
20
|
+
}
|
|
21
|
+
function getConfigPath(workflow) {
|
|
22
|
+
return path.join(WORKFLOW_DIR, `${workflow}.config.json`);
|
|
23
|
+
}
|
|
24
|
+
// ─── Config Persistence ───────────────────────────────────────────────────
|
|
25
|
+
export function loadWorkflowConfig(workflowId) {
|
|
26
|
+
try {
|
|
27
|
+
const p = getConfigPath(workflowId);
|
|
28
|
+
if (fs.existsSync(p)) {
|
|
29
|
+
const raw = JSON.parse(fs.readFileSync(p, 'utf-8'));
|
|
30
|
+
raw.models = { ...DEFAULT_MODEL_TIERS, ...raw.models };
|
|
31
|
+
raw.name = workflowId;
|
|
32
|
+
return raw;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch { /* corrupt */ }
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
export function saveWorkflowConfig(workflowId, config) {
|
|
39
|
+
ensureDir();
|
|
40
|
+
fs.writeFileSync(getConfigPath(workflowId), JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
|
|
41
|
+
}
|
|
42
|
+
function trackAction(workflow, action, key, metadata = {}, costUsd = 0) {
|
|
43
|
+
ensureDir();
|
|
44
|
+
const entry = {
|
|
45
|
+
workflow,
|
|
46
|
+
action,
|
|
47
|
+
key,
|
|
48
|
+
metadata,
|
|
49
|
+
costUsd,
|
|
50
|
+
createdAt: new Date().toISOString(),
|
|
51
|
+
};
|
|
52
|
+
fs.appendFileSync(getDbPath(workflow), JSON.stringify(entry) + '\n');
|
|
53
|
+
}
|
|
54
|
+
function isDuplicate(workflow, key) {
|
|
55
|
+
const dbPath = getDbPath(workflow);
|
|
56
|
+
if (!fs.existsSync(dbPath))
|
|
57
|
+
return false;
|
|
58
|
+
try {
|
|
59
|
+
const lines = fs.readFileSync(dbPath, 'utf-8').split('\n').filter(Boolean);
|
|
60
|
+
for (const line of lines) {
|
|
61
|
+
try {
|
|
62
|
+
const entry = JSON.parse(line);
|
|
63
|
+
if (entry.key === key)
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
catch { /* skip */ }
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
catch { /* no db */ }
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
export function getStats(workflow) {
|
|
73
|
+
const stats = {
|
|
74
|
+
totalRuns: 0,
|
|
75
|
+
totalActions: 0,
|
|
76
|
+
totalCostUsd: 0,
|
|
77
|
+
todayActions: 0,
|
|
78
|
+
todayCostUsd: 0,
|
|
79
|
+
byAction: {},
|
|
80
|
+
};
|
|
81
|
+
const dbPath = getDbPath(workflow);
|
|
82
|
+
if (!fs.existsSync(dbPath))
|
|
83
|
+
return stats;
|
|
84
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
85
|
+
try {
|
|
86
|
+
const lines = fs.readFileSync(dbPath, 'utf-8').split('\n').filter(Boolean);
|
|
87
|
+
for (const line of lines) {
|
|
88
|
+
try {
|
|
89
|
+
const entry = JSON.parse(line);
|
|
90
|
+
stats.totalActions++;
|
|
91
|
+
stats.totalCostUsd += entry.costUsd;
|
|
92
|
+
stats.byAction[entry.action] = (stats.byAction[entry.action] || 0) + 1;
|
|
93
|
+
if (entry.action === 'run_start')
|
|
94
|
+
stats.totalRuns++;
|
|
95
|
+
if (entry.createdAt.startsWith(today)) {
|
|
96
|
+
stats.todayActions++;
|
|
97
|
+
stats.todayCostUsd += entry.costUsd;
|
|
98
|
+
}
|
|
99
|
+
stats.lastRun = entry.createdAt;
|
|
100
|
+
}
|
|
101
|
+
catch { /* skip */ }
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
catch { /* no db */ }
|
|
105
|
+
return stats;
|
|
106
|
+
}
|
|
107
|
+
export function getByAction(workflow, action) {
|
|
108
|
+
const dbPath = getDbPath(workflow);
|
|
109
|
+
if (!fs.existsSync(dbPath))
|
|
110
|
+
return [];
|
|
111
|
+
try {
|
|
112
|
+
const lines = fs.readFileSync(dbPath, 'utf-8').split('\n').filter(Boolean);
|
|
113
|
+
return lines.map(l => { try {
|
|
114
|
+
return JSON.parse(l);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return null;
|
|
118
|
+
} })
|
|
119
|
+
.filter((e) => e !== null && e.action === action);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return [];
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// ─── Model Tier Resolution ────────────────────────────────────────────────
|
|
126
|
+
function resolveModel(tier, tiers) {
|
|
127
|
+
switch (tier) {
|
|
128
|
+
case 'free': return tiers.free;
|
|
129
|
+
case 'cheap': return tiers.cheap;
|
|
130
|
+
case 'premium': return tiers.premium;
|
|
131
|
+
case 'none': return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// ─── Channel Search Adapter ───────────────────────────────────────────────
|
|
135
|
+
import { listChannelPlugins } from './registry.js';
|
|
136
|
+
/** Default web search fallback using DuckDuckGo HTML */
|
|
137
|
+
async function defaultWebSearch(query, options) {
|
|
138
|
+
const maxResults = Math.min(Math.max(options?.maxResults ?? 8, 1), 20);
|
|
139
|
+
const domainHints = (options?.sources ?? [])
|
|
140
|
+
.map(sourceToDomainHint)
|
|
141
|
+
.filter((domain) => Boolean(domain));
|
|
142
|
+
const scopedQueries = Array.from(new Set([
|
|
143
|
+
...domainHints.map((domain) => `${query} site:${domain}`),
|
|
144
|
+
query,
|
|
145
|
+
])).slice(0, 3);
|
|
146
|
+
const merged = [];
|
|
147
|
+
const seenUrls = new Set();
|
|
148
|
+
for (const scoped of scopedQueries) {
|
|
149
|
+
const results = await searchDuckDuckGo(scoped, maxResults);
|
|
150
|
+
for (const result of results) {
|
|
151
|
+
if (seenUrls.has(result.url))
|
|
152
|
+
continue;
|
|
153
|
+
seenUrls.add(result.url);
|
|
154
|
+
merged.push(result);
|
|
155
|
+
if (merged.length >= maxResults)
|
|
156
|
+
return merged;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (merged.length === 0 && scopedQueries[scopedQueries.length - 1] !== query) {
|
|
160
|
+
return searchDuckDuckGo(query, maxResults);
|
|
161
|
+
}
|
|
162
|
+
return merged;
|
|
163
|
+
}
|
|
164
|
+
function sourceToDomainHint(source) {
|
|
165
|
+
const normalized = source.toLowerCase();
|
|
166
|
+
if (normalized === 'reddit')
|
|
167
|
+
return 'reddit.com';
|
|
168
|
+
if (normalized === 'x' || normalized === 'twitter')
|
|
169
|
+
return 'x.com';
|
|
170
|
+
if (normalized === 'web')
|
|
171
|
+
return null;
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
async function searchDuckDuckGo(query, maxResults) {
|
|
175
|
+
try {
|
|
176
|
+
const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`;
|
|
177
|
+
const controller = new AbortController();
|
|
178
|
+
const timer = setTimeout(() => controller.abort(), 15_000);
|
|
179
|
+
const response = await fetch(url, {
|
|
180
|
+
signal: controller.signal,
|
|
181
|
+
headers: { 'User-Agent': USER_AGENT },
|
|
182
|
+
});
|
|
183
|
+
clearTimeout(timer);
|
|
184
|
+
if (!response.ok)
|
|
185
|
+
return [];
|
|
186
|
+
const html = await response.text();
|
|
187
|
+
return parseDuckDuckGoResults(html, maxResults);
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
return [];
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
function parseDuckDuckGoResults(html, maxResults) {
|
|
194
|
+
const results = [];
|
|
195
|
+
const linkRegex = /<a[^>]*class="result__a"[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi;
|
|
196
|
+
const snippetRegex = /<a[^>]*class="result__snippet"[^>]*>([\s\S]*?)<\/a>/gi;
|
|
197
|
+
let links = [...html.matchAll(linkRegex)];
|
|
198
|
+
const snippets = [...html.matchAll(snippetRegex)];
|
|
199
|
+
if (links.length === 0) {
|
|
200
|
+
const fallbackLink = /<a[^>]*class="[^"]*result[^"]*"[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi;
|
|
201
|
+
links = [...html.matchAll(fallbackLink)];
|
|
202
|
+
}
|
|
203
|
+
for (let i = 0; i < Math.min(links.length, maxResults); i++) {
|
|
204
|
+
const link = links[i];
|
|
205
|
+
const snippet = snippets[i];
|
|
206
|
+
const decodedUrl = decodeDuckDuckGoUrl(link[1] ?? '');
|
|
207
|
+
if (!decodedUrl || decodedUrl.startsWith('/') || decodedUrl.includes('duckduckgo.com'))
|
|
208
|
+
continue;
|
|
209
|
+
results.push({
|
|
210
|
+
title: stripHtml(link[2] ?? '').trim(),
|
|
211
|
+
url: decodedUrl,
|
|
212
|
+
snippet: stripHtml(snippet?.[1] ?? '').trim(),
|
|
213
|
+
source: inferSource(decodedUrl),
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
return results;
|
|
217
|
+
}
|
|
218
|
+
function decodeDuckDuckGoUrl(url) {
|
|
219
|
+
const uddg = url.match(/[?&]uddg=([^&]+)/);
|
|
220
|
+
if (uddg?.[1]) {
|
|
221
|
+
try {
|
|
222
|
+
return decodeURIComponent(uddg[1]);
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
return url;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return url;
|
|
229
|
+
}
|
|
230
|
+
function inferSource(url) {
|
|
231
|
+
const lower = url.toLowerCase();
|
|
232
|
+
if (lower.includes('reddit.com'))
|
|
233
|
+
return 'reddit';
|
|
234
|
+
if (lower.includes('x.com') || lower.includes('twitter.com'))
|
|
235
|
+
return 'x';
|
|
236
|
+
return 'web';
|
|
237
|
+
}
|
|
238
|
+
function stripHtml(input) {
|
|
239
|
+
return input
|
|
240
|
+
.replace(/<[^>]+>/g, '')
|
|
241
|
+
.replace(/&/g, '&')
|
|
242
|
+
.replace(/</g, '<')
|
|
243
|
+
.replace(/>/g, '>')
|
|
244
|
+
.replace(/"/g, '"')
|
|
245
|
+
.replace(/'/g, '\'')
|
|
246
|
+
.replace(/ /g, ' ')
|
|
247
|
+
.replace(/\s+/g, ' ');
|
|
248
|
+
}
|
|
249
|
+
/** Resolve channel by id and call its search method */
|
|
250
|
+
async function searchViaChannel(channelId, query, options) {
|
|
251
|
+
const channelPlugins = listChannelPlugins();
|
|
252
|
+
for (const cp of channelPlugins) {
|
|
253
|
+
if (cp.plugin.channels?.[channelId]) {
|
|
254
|
+
const channel = cp.plugin.channels[channelId]();
|
|
255
|
+
try {
|
|
256
|
+
const result = await channel.search(query, {
|
|
257
|
+
log: (msg) => process.stderr.write(`[${channelId}] ${msg}\n`),
|
|
258
|
+
dryRun: false,
|
|
259
|
+
}, { maxResults: options?.maxResults });
|
|
260
|
+
return result.posts.map(p => ({
|
|
261
|
+
title: p.title,
|
|
262
|
+
url: p.url,
|
|
263
|
+
snippet: p.body,
|
|
264
|
+
source: p.platform,
|
|
265
|
+
author: p.author,
|
|
266
|
+
timestamp: p.createdAt,
|
|
267
|
+
score: p.score,
|
|
268
|
+
commentCount: p.commentCount,
|
|
269
|
+
}));
|
|
270
|
+
}
|
|
271
|
+
catch (err) {
|
|
272
|
+
process.stderr.write(`[${channelId}] search failed: ${err.message}\n`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return [];
|
|
277
|
+
}
|
|
278
|
+
// ─── Workflow Runner ──────────────────────────────────────────────────────
|
|
279
|
+
export async function runWorkflow(workflow, config, client, options = {}) {
|
|
280
|
+
const dryRun = options.dryRun ?? false;
|
|
281
|
+
const start = Date.now();
|
|
282
|
+
const stepResults = [];
|
|
283
|
+
let totalCost = 0;
|
|
284
|
+
let itemsProcessed = 0;
|
|
285
|
+
const data = {};
|
|
286
|
+
const tiers = config.models ?? DEFAULT_MODEL_TIERS;
|
|
287
|
+
trackAction(workflow.id, 'run_start', `run-${Date.now()}`, { dryRun });
|
|
288
|
+
// Lifecycle hook
|
|
289
|
+
if (workflow.beforeRun) {
|
|
290
|
+
try {
|
|
291
|
+
await workflow.beforeRun(config);
|
|
292
|
+
}
|
|
293
|
+
catch (err) {
|
|
294
|
+
process.stderr.write(`[${workflow.id}] beforeRun failed: ${err.message}\n`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
const ctx = {
|
|
298
|
+
data,
|
|
299
|
+
config,
|
|
300
|
+
dryRun,
|
|
301
|
+
callModel: async (tier, prompt, system) => {
|
|
302
|
+
if (tier === 'none')
|
|
303
|
+
throw new Error('Cannot call model with tier "none"');
|
|
304
|
+
const model = resolveModel(tier, tiers);
|
|
305
|
+
if (!model)
|
|
306
|
+
throw new Error(`No model resolved for tier ${tier}`);
|
|
307
|
+
const result = await client.complete({
|
|
308
|
+
model,
|
|
309
|
+
messages: [{ role: 'user', content: prompt }],
|
|
310
|
+
system,
|
|
311
|
+
max_tokens: 4096,
|
|
312
|
+
stream: true,
|
|
313
|
+
});
|
|
314
|
+
let text = '';
|
|
315
|
+
for (const part of result.content) {
|
|
316
|
+
if (part.type === 'text')
|
|
317
|
+
text += part.text;
|
|
318
|
+
}
|
|
319
|
+
const cost = estimateCost(model, result.usage.inputTokens, result.usage.outputTokens, 1);
|
|
320
|
+
totalCost += cost;
|
|
321
|
+
return text;
|
|
322
|
+
},
|
|
323
|
+
search: async (query, opts) => {
|
|
324
|
+
// Try channel search first if scope hints at a channel
|
|
325
|
+
if (opts?.sources && opts.sources.length > 0) {
|
|
326
|
+
for (const source of opts.sources) {
|
|
327
|
+
const results = await searchViaChannel(source, query, opts);
|
|
328
|
+
if (results.length > 0)
|
|
329
|
+
return results;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return defaultWebSearch(query, opts);
|
|
333
|
+
},
|
|
334
|
+
sendMessage: async (channelId, message) => {
|
|
335
|
+
if (dryRun) {
|
|
336
|
+
process.stderr.write(`[${workflow.id}] [dry-run] would send to ${channelId}\n`);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
const channelPlugins = listChannelPlugins();
|
|
340
|
+
for (const cp of channelPlugins) {
|
|
341
|
+
if (cp.plugin.channels?.[channelId]) {
|
|
342
|
+
const channel = cp.plugin.channels[channelId]();
|
|
343
|
+
await channel.post(message, {
|
|
344
|
+
log: (msg) => process.stderr.write(`[${channelId}] ${msg}\n`),
|
|
345
|
+
dryRun,
|
|
346
|
+
});
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
throw new Error(`Channel "${channelId}" not found`);
|
|
351
|
+
},
|
|
352
|
+
log: (msg) => process.stderr.write(`[${workflow.id}] ${msg}\n`),
|
|
353
|
+
track: async (action, metadata) => {
|
|
354
|
+
trackAction(workflow.id, action, `${action}-${Date.now()}`, metadata, 0);
|
|
355
|
+
},
|
|
356
|
+
isDuplicate: async (key) => isDuplicate(workflow.id, key),
|
|
357
|
+
};
|
|
358
|
+
for (const step of workflow.steps) {
|
|
359
|
+
if (dryRun && step.skipInDryRun) {
|
|
360
|
+
stepResults.push({ name: step.name, summary: '[dry-run] skipped', cost: 0, status: 'skipped' });
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
process.stderr.write(`[${workflow.id}] → ${step.name}...\n`);
|
|
364
|
+
try {
|
|
365
|
+
const result = await step.execute(ctx);
|
|
366
|
+
if (result.data)
|
|
367
|
+
Object.assign(data, result.data);
|
|
368
|
+
const stepCost = result.cost ?? 0;
|
|
369
|
+
totalCost += stepCost;
|
|
370
|
+
if (result.data?.itemCount)
|
|
371
|
+
itemsProcessed += result.data.itemCount;
|
|
372
|
+
stepResults.push({
|
|
373
|
+
name: step.name,
|
|
374
|
+
summary: result.summary ?? 'done',
|
|
375
|
+
cost: stepCost,
|
|
376
|
+
status: result.abort ? 'aborted' : 'ok',
|
|
377
|
+
});
|
|
378
|
+
if (result.abort) {
|
|
379
|
+
process.stderr.write(`[${workflow.id}] ⚠ ${step.name}: ${result.summary ?? 'aborted'}\n`);
|
|
380
|
+
break;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
catch (err) {
|
|
384
|
+
const errMsg = err.message;
|
|
385
|
+
process.stderr.write(`[${workflow.id}] ✗ ${step.name}: ${errMsg}\n`);
|
|
386
|
+
stepResults.push({ name: step.name, summary: `error: ${errMsg}`, cost: 0, status: 'error' });
|
|
387
|
+
break;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
const result = {
|
|
391
|
+
steps: stepResults,
|
|
392
|
+
totalCost,
|
|
393
|
+
itemsProcessed,
|
|
394
|
+
durationMs: Date.now() - start,
|
|
395
|
+
dryRun,
|
|
396
|
+
};
|
|
397
|
+
trackAction(workflow.id, 'run_complete', `run-${Date.now()}`, {
|
|
398
|
+
dryRun, totalCost, itemsProcessed, durationMs: result.durationMs,
|
|
399
|
+
}, totalCost);
|
|
400
|
+
if (workflow.afterRun) {
|
|
401
|
+
try {
|
|
402
|
+
await workflow.afterRun(result);
|
|
403
|
+
}
|
|
404
|
+
catch (err) {
|
|
405
|
+
process.stderr.write(`[${workflow.id}] afterRun failed: ${err.message}\n`);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return result;
|
|
409
|
+
}
|
|
410
|
+
// ─── Display ──────────────────────────────────────────────────────────────
|
|
411
|
+
export function formatWorkflowResult(workflow, result) {
|
|
412
|
+
const lines = [];
|
|
413
|
+
const sep = '─'.repeat(50);
|
|
414
|
+
lines.push(`\n${sep}`);
|
|
415
|
+
lines.push(`${workflow.name.toUpperCase()} ${result.dryRun ? '[DRY RUN]' : 'COMPLETE'}`);
|
|
416
|
+
lines.push(sep);
|
|
417
|
+
for (const step of result.steps) {
|
|
418
|
+
const costStr = step.cost > 0 ? ` ($${step.cost.toFixed(4)})` : '';
|
|
419
|
+
const icon = step.status === 'error'
|
|
420
|
+
? '✗'
|
|
421
|
+
: step.status === 'aborted'
|
|
422
|
+
? '⚠'
|
|
423
|
+
: step.status === 'skipped'
|
|
424
|
+
? '○'
|
|
425
|
+
: '✓';
|
|
426
|
+
lines.push(` ${icon} ${step.name}: ${step.summary}${costStr}`);
|
|
427
|
+
}
|
|
428
|
+
lines.push(sep);
|
|
429
|
+
lines.push(` Items: ${result.itemsProcessed} Cost: $${result.totalCost.toFixed(4)} Time: ${(result.durationMs / 1000).toFixed(1)}s`);
|
|
430
|
+
lines.push(`${sep}\n`);
|
|
431
|
+
return lines.join('\n');
|
|
432
|
+
}
|
|
433
|
+
export function formatWorkflowStats(workflow, stats) {
|
|
434
|
+
const lines = [];
|
|
435
|
+
const sep = '─'.repeat(40);
|
|
436
|
+
lines.push(`\n${sep}\n${workflow.name.toUpperCase()} STATS\n${sep}`);
|
|
437
|
+
lines.push(` Total runs: ${stats.totalRuns}`);
|
|
438
|
+
lines.push(` Total actions: ${stats.totalActions}`);
|
|
439
|
+
lines.push(` Total cost: $${stats.totalCostUsd.toFixed(4)}`);
|
|
440
|
+
lines.push(` Today: ${stats.todayActions} actions, $${stats.todayCostUsd.toFixed(4)}`);
|
|
441
|
+
if (stats.lastRun)
|
|
442
|
+
lines.push(` Last run: ${stats.lastRun}`);
|
|
443
|
+
if (Object.keys(stats.byAction).length > 0) {
|
|
444
|
+
lines.push(` By action:`);
|
|
445
|
+
for (const [action, count] of Object.entries(stats.byAction)) {
|
|
446
|
+
if (action !== 'run_start' && action !== 'run_complete') {
|
|
447
|
+
lines.push(` ${action}: ${count}`);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
lines.push(`${sep}\n`);
|
|
452
|
+
return lines.join('\n');
|
|
453
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Social Workflow Plugin.
|
|
3
|
+
*
|
|
4
|
+
* IMPORTANT: This file ONLY imports from `../../plugin-sdk/`.
|
|
5
|
+
* It does NOT import from `src/agent/`, `src/commands/`, `src/social/`, etc.
|
|
6
|
+
* This is the boundary that keeps plugins decoupled from core internals.
|
|
7
|
+
*/
|
|
8
|
+
import type { Plugin } from '../../plugin-sdk/index.js';
|
|
9
|
+
declare const plugin: Plugin;
|
|
10
|
+
export default plugin;
|