@bahulam/code 0.1.10 → 0.1.12
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/package.json +5 -2
- package/src/agents/loader.mjs +26 -4
- package/src/auth/bahulam-auth.mjs +2 -13
- package/src/auth/tarang-auth.mjs +313 -0
- package/src/commands/agent.mjs +7 -7
- package/src/commands/plugin-manage.mjs +449 -0
- package/src/commands/plugin.mjs +247 -0
- package/src/config/cli-args.mjs +16 -0
- package/src/config/env.mjs +2 -0
- package/src/config/hook-runner.mjs +8 -8
- package/src/config/memory-loader.mjs +7 -3
- package/src/config/settings-loader.mjs +5 -3
- package/src/core/attachments.mjs +2 -2
- package/src/core/background-tasks.mjs +186 -0
- package/src/core/headless.mjs +59 -3
- package/src/core/local-agent.mjs +10 -1
- package/src/core/local-store.mjs +10 -10
- package/src/core/paths.mjs +10 -96
- package/src/core/policy-resolver.mjs +1 -1
- package/src/core/project-context-loader.mjs +2 -2
- package/src/core/risk-tier.mjs +1 -0
- package/src/core/stream-client.mjs +148 -1
- package/src/core/system-prompt.mjs +31 -12
- package/src/core/tool-executor.mjs +457 -12
- package/src/local-service/agent-relay.mjs +139 -12
- package/src/local-service/server.mjs +345 -20
- package/src/orchestration/approval.mjs +30 -0
- package/src/orchestration/completion-triggers.mjs +40 -0
- package/src/orchestration/dispatch.mjs +118 -0
- package/src/orchestration/events.mjs +19 -0
- package/src/orchestration/graph.mjs +126 -0
- package/src/orchestration/node-runner.mjs +193 -0
- package/src/orchestration/runner.mjs +200 -0
- package/src/plugins/executor.mjs +121 -0
- package/src/plugins/loader.mjs +123 -123
- package/src/plugins/manifest.mjs +291 -0
- package/src/plugins/preflight.mjs +227 -0
- package/src/plugins/registry.mjs +233 -0
- package/src/plugins/state.mjs +290 -0
- package/src/terminal/agents.mjs +26 -4
- package/src/terminal/init.mjs +2 -2
- package/src/terminal/main.mjs +83 -4
- package/src/terminal/repl-explore.mjs +1 -1
- package/src/terminal/repl-render.mjs +67 -12
- package/src/terminal/repl-state.mjs +4 -2
- package/src/terminal/repl.mjs +621 -99
- package/src/tools/agent.mjs +6 -2
- package/src/tools/analyze-image.mjs +1 -1
- package/src/tools/project-overview.mjs +7 -7
- package/src/tools/registry.mjs +88 -4
- package/src/ui/slash-commands.mjs +19 -1
- package/src/ui/sub-agent.mjs +14 -8
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin Registry — scan, load, validate, and deduplicate plugin manifests.
|
|
3
|
+
*
|
|
4
|
+
* Scans standard directories for plugin.yaml / plugin.json manifests.
|
|
5
|
+
* Follows the same pattern as AgentLoader and SkillsLoader.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from 'fs';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import os from 'os';
|
|
11
|
+
import { parsePluginManifestFile, validatePluginManifest } from './manifest.mjs';
|
|
12
|
+
|
|
13
|
+
const DEFAULT_PLUGIN_DIRS = () => [
|
|
14
|
+
path.join(process.cwd(), '.bahulam', 'plugins'),
|
|
15
|
+
path.join(os.homedir(), '.bahulam', 'plugins'),
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
export class PluginRegistry {
|
|
19
|
+
/**
|
|
20
|
+
* @param {Object} [options]
|
|
21
|
+
* @param {string[]} [options.pluginDirs] - Directories to scan (default: project .bahulam/plugins + ~/.bahulam/plugins)
|
|
22
|
+
* @param {string[]} [options.disabled] - Plugin names to skip
|
|
23
|
+
* @param {string[]} [options.enabled] - If provided, only these plugin names are loaded
|
|
24
|
+
* @param {string[]} [options.active] - Alias for enabled
|
|
25
|
+
* @param {string} [options.pluginDir] - Legacy single plugin dir (mapped to pluginDirs[0])
|
|
26
|
+
*/
|
|
27
|
+
constructor({ pluginDirs, disabled = [], enabled = null, active = null, pluginDir } = {}) {
|
|
28
|
+
this.pluginDirs = pluginDirs || (pluginDir ? [pluginDir] : DEFAULT_PLUGIN_DIRS());
|
|
29
|
+
this.disabled = new Set(
|
|
30
|
+
(Array.isArray(disabled) ? disabled : [])
|
|
31
|
+
.map(s => String(s).trim().toLowerCase())
|
|
32
|
+
.filter(Boolean),
|
|
33
|
+
);
|
|
34
|
+
const enabledList = Array.isArray(enabled) ? enabled : (Array.isArray(active) ? active : []);
|
|
35
|
+
this.enabled = new Set(
|
|
36
|
+
enabledList
|
|
37
|
+
.map(s => String(s).trim().toLowerCase())
|
|
38
|
+
.filter(Boolean),
|
|
39
|
+
);
|
|
40
|
+
this.plugins = new Map(); // name → manifest
|
|
41
|
+
this.errors = []; // { name, message }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Scan all plugin directories and load manifests.
|
|
46
|
+
* @returns {this}
|
|
47
|
+
*/
|
|
48
|
+
scan() {
|
|
49
|
+
for (const dir of this.pluginDirs) {
|
|
50
|
+
this._scanDir(dir);
|
|
51
|
+
}
|
|
52
|
+
return this;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
_scanDir(dir) {
|
|
56
|
+
try {
|
|
57
|
+
if (!fs.existsSync(dir)) return;
|
|
58
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
59
|
+
for (const entry of entries) {
|
|
60
|
+
if (!entry.isDirectory()) continue;
|
|
61
|
+
const pluginDir = path.join(dir, entry.name);
|
|
62
|
+
|
|
63
|
+
// Try plugin.yaml, plugin.json (in that order)
|
|
64
|
+
let manifestPath = path.join(pluginDir, 'plugin.yaml');
|
|
65
|
+
if (!fs.existsSync(manifestPath)) {
|
|
66
|
+
manifestPath = path.join(pluginDir, 'plugin.json');
|
|
67
|
+
if (!fs.existsSync(manifestPath)) continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const manifest = parsePluginManifestFile(manifestPath);
|
|
71
|
+
if (!manifest) {
|
|
72
|
+
this.errors.push({
|
|
73
|
+
plugin: entry.name,
|
|
74
|
+
message: `Failed to parse manifest: ${manifestPath}`,
|
|
75
|
+
});
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
this.register(manifest);
|
|
80
|
+
}
|
|
81
|
+
} catch (err) {
|
|
82
|
+
if (process.env.DEBUG) {
|
|
83
|
+
console.error(`Plugin registry scan error in ${dir}: ${err.message}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Register a plugin manifest.
|
|
90
|
+
* @param {object} manifest - Normalized manifest from normalizeManifest()
|
|
91
|
+
* @returns {boolean} true if registered, false if skipped (disabled or duplicate)
|
|
92
|
+
*/
|
|
93
|
+
register(manifest) {
|
|
94
|
+
const name = manifest.metadata?.name || '';
|
|
95
|
+
if (!name) return false;
|
|
96
|
+
|
|
97
|
+
const lowerName = name.toLowerCase();
|
|
98
|
+
const aliases = [
|
|
99
|
+
lowerName,
|
|
100
|
+
manifest._dir ? path.basename(manifest._dir).toLowerCase() : '',
|
|
101
|
+
].filter(Boolean);
|
|
102
|
+
|
|
103
|
+
if (this.enabled.size > 0 && !aliases.some(alias => this.enabled.has(alias))) {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Check disabled list
|
|
108
|
+
if (aliases.some(alias => this.disabled.has(alias))) {
|
|
109
|
+
if (process.env.DEBUG) {
|
|
110
|
+
console.warn(`Plugin "${name}" is disabled, skipping`);
|
|
111
|
+
}
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Check for existing (first wins — project overrides global)
|
|
116
|
+
if (this.plugins.has(lowerName)) {
|
|
117
|
+
return false; // silently skip duplicates
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Validate
|
|
121
|
+
const { valid, errors } = validatePluginManifest(manifest);
|
|
122
|
+
if (!valid) {
|
|
123
|
+
this.errors.push({ plugin: name, message: errors.join('; ') });
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
this.plugins.set(lowerName, manifest);
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Get a plugin by name.
|
|
133
|
+
* @param {string} name
|
|
134
|
+
* @returns {object|null}
|
|
135
|
+
*/
|
|
136
|
+
get(name) {
|
|
137
|
+
return this.plugins.get(String(name || '').toLowerCase()) || null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* List all registered plugins.
|
|
142
|
+
* @returns {object[]}
|
|
143
|
+
*/
|
|
144
|
+
list() {
|
|
145
|
+
return [...this.plugins.values()];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* List all tools from all plugins.
|
|
150
|
+
* @returns {object[]}
|
|
151
|
+
*/
|
|
152
|
+
listTools() {
|
|
153
|
+
const tools = [];
|
|
154
|
+
for (const plugin of this.plugins.values()) {
|
|
155
|
+
for (const tool of (plugin.spec?.tools || [])) {
|
|
156
|
+
tools.push({
|
|
157
|
+
...tool,
|
|
158
|
+
_plugin_name: plugin.metadata?.name,
|
|
159
|
+
_plugin_dir: plugin._dir,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return tools;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* List every plugin-declared MCP server across the registry.
|
|
168
|
+
*
|
|
169
|
+
* Each entry is [{plugin, name, config}] where `config` is a
|
|
170
|
+
* Claude-Desktop-compatible object (command/args/env or url/headers).
|
|
171
|
+
* Consumers spawn one McpClient per entry at session start; the
|
|
172
|
+
* server's tools are then namespaced as `<name>.<tool>` in the
|
|
173
|
+
* tool executor so two plugins can ship servers with the same tool
|
|
174
|
+
* name without collision.
|
|
175
|
+
* @returns {{plugin: string, name: string, config: object}[]}
|
|
176
|
+
*/
|
|
177
|
+
listMcpServers() {
|
|
178
|
+
const out = [];
|
|
179
|
+
for (const plugin of this.plugins.values()) {
|
|
180
|
+
const servers = plugin.spec?.mcpServers || {};
|
|
181
|
+
const pluginName = plugin.metadata?.name || '';
|
|
182
|
+
for (const [name, config] of Object.entries(servers)) {
|
|
183
|
+
if (config && typeof config === 'object') {
|
|
184
|
+
out.push({ plugin: pluginName, name, config });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* List all agents from all plugins.
|
|
193
|
+
* @returns {object[]}
|
|
194
|
+
*/
|
|
195
|
+
listAgents() {
|
|
196
|
+
const agents = [];
|
|
197
|
+
for (const plugin of this.plugins.values()) {
|
|
198
|
+
for (const agent of (plugin.spec?.agents || [])) {
|
|
199
|
+
agents.push({
|
|
200
|
+
...agent,
|
|
201
|
+
_plugin_name: plugin.metadata?.name,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return agents;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Check if a plugin exists.
|
|
210
|
+
* @param {string} name
|
|
211
|
+
* @returns {boolean}
|
|
212
|
+
*/
|
|
213
|
+
has(name) {
|
|
214
|
+
return this.plugins.has(String(name || '').toLowerCase());
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Remove a plugin by name.
|
|
219
|
+
* @param {string} name
|
|
220
|
+
* @returns {boolean}
|
|
221
|
+
*/
|
|
222
|
+
remove(name) {
|
|
223
|
+
return this.plugins.delete(String(name || '').toLowerCase());
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Get plugin count.
|
|
228
|
+
* @returns {number}
|
|
229
|
+
*/
|
|
230
|
+
count() {
|
|
231
|
+
return this.plugins.size;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin state — the Shared Blackboard.
|
|
3
|
+
*
|
|
4
|
+
* Every plugin gets its own SQLite sidecar at ~/.bahulam/data/<name>/state.db.
|
|
5
|
+
* Two built-in tables ship with every DB; plugins are free to `CREATE TABLE`
|
|
6
|
+
* additional ones via `state.query(sql)`:
|
|
7
|
+
*
|
|
8
|
+
* kv — small structured values (watchlists, prefs, cursors, form
|
|
9
|
+
* state). One row per key, JSON-encoded value, last-write-wins
|
|
10
|
+
* with an updated_at timestamp.
|
|
11
|
+
*
|
|
12
|
+
* records — append-only event log (backtest runs, decisions, alerts,
|
|
13
|
+
* anything you want a history of). Named streams via the
|
|
14
|
+
* `stream` column; plugin owns the stream namespace.
|
|
15
|
+
*
|
|
16
|
+
* The exported `makePluginState(pluginName, {emit})` returns a proxy with
|
|
17
|
+
* the methods handlers and view routes call. Every write fires `emit(evt)`
|
|
18
|
+
* synchronously AFTER commit — the SSE bus turns that into a
|
|
19
|
+
* `plugin_state_changed` event so views can re-render live. When the same
|
|
20
|
+
* key/stream is written many times in quick succession the emit hook
|
|
21
|
+
* debounces to at most one event per 50ms per (plugin, kind, target).
|
|
22
|
+
*
|
|
23
|
+
* We use Node's built-in `node:sqlite` (v22+, experimental) so plugin
|
|
24
|
+
* authors get relational storage with zero install steps. The
|
|
25
|
+
* ExperimentalWarning is silenced once at module load.
|
|
26
|
+
*
|
|
27
|
+
* A NOTE ON SAFETY: `state.query(sql, params)` is a raw SQL escape hatch
|
|
28
|
+
* intended for the plugin's OWN tools and views — never expose it to
|
|
29
|
+
* untrusted input (the plugin author owns the SQL). The higher-level
|
|
30
|
+
* get/set/patch/append/list methods parameterize everything.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import fs from 'node:fs';
|
|
34
|
+
import os from 'node:os';
|
|
35
|
+
import path from 'node:path';
|
|
36
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
37
|
+
|
|
38
|
+
// Silence the single "SQLite is an experimental feature" warning that
|
|
39
|
+
// node:sqlite emits at first import. Users would see it on every plugin
|
|
40
|
+
// launch otherwise, which is noise, not signal.
|
|
41
|
+
{
|
|
42
|
+
const orig = process.emit;
|
|
43
|
+
process.emit = function (name, warning, ...rest) {
|
|
44
|
+
if (name === 'warning' && warning?.name === 'ExperimentalWarning'
|
|
45
|
+
&& /SQLite/i.test(String(warning.message || ''))) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
return orig.call(this, name, warning, ...rest);
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const DATA_ROOT = () => path.join(os.homedir(), '.bahulam', 'data');
|
|
53
|
+
const PLUGIN_NAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
|
|
54
|
+
const DEBOUNCE_MS = 50;
|
|
55
|
+
|
|
56
|
+
// Open handles cached per plugin — SQLite in WAL mode is happy with one
|
|
57
|
+
// handle per process, and this is a single-process dev tool. Handles live
|
|
58
|
+
// for the lifetime of the CLI; explicit close() is available for tests.
|
|
59
|
+
const _handles = new Map(); // pluginName -> { db, dir, path }
|
|
60
|
+
|
|
61
|
+
function pluginDataDir(pluginName) {
|
|
62
|
+
if (!PLUGIN_NAME_RE.test(pluginName)) {
|
|
63
|
+
throw new Error(`invalid plugin name for state dir: ${pluginName}`);
|
|
64
|
+
}
|
|
65
|
+
const dir = path.join(DATA_ROOT(), pluginName);
|
|
66
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
67
|
+
return dir;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function openDb(pluginName) {
|
|
71
|
+
if (_handles.has(pluginName)) return _handles.get(pluginName);
|
|
72
|
+
const dir = pluginDataDir(pluginName);
|
|
73
|
+
const dbPath = path.join(dir, 'state.db');
|
|
74
|
+
const db = new DatabaseSync(dbPath);
|
|
75
|
+
// WAL: multiple readers, one writer; robust against concurrent view+agent.
|
|
76
|
+
db.exec('PRAGMA journal_mode = WAL');
|
|
77
|
+
db.exec('PRAGMA synchronous = NORMAL');
|
|
78
|
+
db.exec('PRAGMA foreign_keys = ON');
|
|
79
|
+
// Bootstrap schema — idempotent so evolving plugins never crash on start.
|
|
80
|
+
db.exec(`
|
|
81
|
+
CREATE TABLE IF NOT EXISTS kv (
|
|
82
|
+
key TEXT PRIMARY KEY,
|
|
83
|
+
value TEXT NOT NULL,
|
|
84
|
+
updated_at TEXT NOT NULL
|
|
85
|
+
);
|
|
86
|
+
CREATE TABLE IF NOT EXISTS records (
|
|
87
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
88
|
+
stream TEXT NOT NULL,
|
|
89
|
+
payload TEXT NOT NULL,
|
|
90
|
+
created_at TEXT NOT NULL
|
|
91
|
+
);
|
|
92
|
+
CREATE INDEX IF NOT EXISTS records_stream_idx
|
|
93
|
+
ON records(stream, id DESC);
|
|
94
|
+
`);
|
|
95
|
+
const handle = { db, dir, path: dbPath };
|
|
96
|
+
_handles.set(pluginName, handle);
|
|
97
|
+
return handle;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function now() { return new Date().toISOString(); }
|
|
101
|
+
|
|
102
|
+
function deepMerge(base, patch) {
|
|
103
|
+
if (Array.isArray(base) || Array.isArray(patch)) return patch;
|
|
104
|
+
if (base == null || typeof base !== 'object') return patch;
|
|
105
|
+
if (patch == null || typeof patch !== 'object') return patch;
|
|
106
|
+
const out = { ...base };
|
|
107
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
108
|
+
if (v && typeof v === 'object' && !Array.isArray(v)
|
|
109
|
+
&& out[k] && typeof out[k] === 'object' && !Array.isArray(out[k])) {
|
|
110
|
+
out[k] = deepMerge(out[k], v);
|
|
111
|
+
} else {
|
|
112
|
+
out[k] = v;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Build a per-plugin state proxy.
|
|
120
|
+
* @param {string} pluginName Must match /^[a-z0-9][a-z0-9._-]{0,63}$/i
|
|
121
|
+
* @param {object} [opts]
|
|
122
|
+
* @param {(evt: {plugin: string, op: string, kind: 'kv'|'records', target: string, at: string}) => void} [opts.emit]
|
|
123
|
+
* Called (debounced) after every write commits. The workspace server
|
|
124
|
+
* turns this into an SSE `plugin_state_changed` event for the browser.
|
|
125
|
+
* @returns proxy with { get, set, patch, append, list, query, delete, close, db, path }
|
|
126
|
+
*/
|
|
127
|
+
export function makePluginState(pluginName, { emit = null } = {}) {
|
|
128
|
+
const { db, path: dbPath } = openDb(pluginName);
|
|
129
|
+
|
|
130
|
+
// One debounce timer per (kind, target). Fast writes coalesce into
|
|
131
|
+
// exactly one plugin_state_changed event. Pending entry is stored so
|
|
132
|
+
// close() can flush synchronously for tests and controlled shutdown.
|
|
133
|
+
const timers = new Map(); // key -> timeout handle
|
|
134
|
+
const pending = new Map(); // key -> event payload to fire on flush
|
|
135
|
+
function fire(op, kind, target) {
|
|
136
|
+
if (typeof emit !== 'function') return;
|
|
137
|
+
const key = `${kind}:${target}`;
|
|
138
|
+
if (timers.has(key)) clearTimeout(timers.get(key));
|
|
139
|
+
pending.set(key, { plugin: pluginName, op, kind, target, at: now() });
|
|
140
|
+
timers.set(key, setTimeout(() => {
|
|
141
|
+
const evt = pending.get(key);
|
|
142
|
+
timers.delete(key);
|
|
143
|
+
pending.delete(key);
|
|
144
|
+
if (evt) {
|
|
145
|
+
try { emit(evt); }
|
|
146
|
+
catch { /* emit failure must never surface into the tool call */ }
|
|
147
|
+
}
|
|
148
|
+
}, DEBOUNCE_MS));
|
|
149
|
+
}
|
|
150
|
+
function flushPending() {
|
|
151
|
+
for (const [key, evt] of pending) {
|
|
152
|
+
clearTimeout(timers.get(key));
|
|
153
|
+
try { emit(evt); } catch { /* swallow */ }
|
|
154
|
+
}
|
|
155
|
+
timers.clear();
|
|
156
|
+
pending.clear();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Prepared statements are cached on first use (better-sqlite3-style perf,
|
|
160
|
+
// node:sqlite exposes prepare() too).
|
|
161
|
+
const stmts = {
|
|
162
|
+
get: db.prepare('SELECT value FROM kv WHERE key = ?'),
|
|
163
|
+
upsert: db.prepare('INSERT INTO kv(key, value, updated_at) VALUES(?, ?, ?) '
|
|
164
|
+
+ 'ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at'),
|
|
165
|
+
del: db.prepare('DELETE FROM kv WHERE key = ?'),
|
|
166
|
+
keys: db.prepare('SELECT key FROM kv ORDER BY key'),
|
|
167
|
+
append: db.prepare('INSERT INTO records(stream, payload, created_at) VALUES(?, ?, ?)'),
|
|
168
|
+
listAsc: db.prepare('SELECT id, payload, created_at FROM records WHERE stream = ? ORDER BY id ASC LIMIT ?'),
|
|
169
|
+
listDesc: db.prepare('SELECT id, payload, created_at FROM records WHERE stream = ? ORDER BY id DESC LIMIT ?'),
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
/** Read one key. Returns `fallback` (default null) when the key is absent. */
|
|
174
|
+
get(key, fallback = null) {
|
|
175
|
+
const row = stmts.get.get(String(key));
|
|
176
|
+
if (!row) return fallback;
|
|
177
|
+
try { return JSON.parse(row.value); }
|
|
178
|
+
catch { return fallback; }
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
/** Write one key with a whole value. Fires plugin_state_changed. */
|
|
182
|
+
set(key, value) {
|
|
183
|
+
const k = String(key);
|
|
184
|
+
stmts.upsert.run(k, JSON.stringify(value), now());
|
|
185
|
+
fire('set', 'kv', k);
|
|
186
|
+
return value;
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Deep-merge a partial into an existing object under `key`. Arrays
|
|
191
|
+
* replace; nested objects merge recursively. When the key is absent,
|
|
192
|
+
* `patch` is stored as-is. Matches the "PATCH endpoint for surgeon
|
|
193
|
+
* precision" contract.
|
|
194
|
+
*/
|
|
195
|
+
patch(key, partial) {
|
|
196
|
+
const k = String(key);
|
|
197
|
+
const cur = this.get(k, null);
|
|
198
|
+
const next = deepMerge(cur, partial);
|
|
199
|
+
stmts.upsert.run(k, JSON.stringify(next), now());
|
|
200
|
+
fire('patch', 'kv', k);
|
|
201
|
+
return next;
|
|
202
|
+
},
|
|
203
|
+
|
|
204
|
+
/** Remove one key. */
|
|
205
|
+
delete(key) {
|
|
206
|
+
const k = String(key);
|
|
207
|
+
const info = stmts.del.run(k);
|
|
208
|
+
if (info.changes > 0) fire('delete', 'kv', k);
|
|
209
|
+
return info.changes > 0;
|
|
210
|
+
},
|
|
211
|
+
|
|
212
|
+
/** List every kv key currently present. */
|
|
213
|
+
keys() { return stmts.keys.all().map(r => r.key); },
|
|
214
|
+
|
|
215
|
+
/** Append one row to a named stream. Returns the new row's id. */
|
|
216
|
+
append(stream, payload) {
|
|
217
|
+
const s = String(stream);
|
|
218
|
+
const info = stmts.append.run(s, JSON.stringify(payload), now());
|
|
219
|
+
fire('append', 'records', s);
|
|
220
|
+
return Number(info.lastInsertRowid);
|
|
221
|
+
},
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Read from a stream.
|
|
225
|
+
* @param {string} stream
|
|
226
|
+
* @param {object} [opts] { limit?: number = 50, order?: 'asc'|'desc' = 'desc' }
|
|
227
|
+
* @returns [{ id, payload, created_at }]
|
|
228
|
+
*/
|
|
229
|
+
list(stream, { limit = 50, order = 'desc' } = {}) {
|
|
230
|
+
const s = String(stream);
|
|
231
|
+
const cap = Math.max(1, Math.min(10000, Math.floor(limit)));
|
|
232
|
+
const stmt = order === 'asc' ? stmts.listAsc : stmts.listDesc;
|
|
233
|
+
return stmt.all(s, cap).map(r => ({
|
|
234
|
+
id: r.id,
|
|
235
|
+
payload: safeParse(r.payload),
|
|
236
|
+
created_at: r.created_at,
|
|
237
|
+
}));
|
|
238
|
+
},
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Raw SQL escape hatch — for the plugin's own advanced use. Always
|
|
242
|
+
* use parameters, never string-concat user input into SQL. Returns
|
|
243
|
+
* whatever the underlying prepared statement returns; SELECTs come
|
|
244
|
+
* back as an array of rows.
|
|
245
|
+
*/
|
|
246
|
+
query(sql, params = []) {
|
|
247
|
+
const stmt = db.prepare(String(sql));
|
|
248
|
+
const args = Array.isArray(params) ? params : [params];
|
|
249
|
+
// node:sqlite prepared statements expose all() for SELECT-like
|
|
250
|
+
// queries and run() for DML; iterate() is available too.
|
|
251
|
+
const first = String(sql).trim().slice(0, 6).toUpperCase();
|
|
252
|
+
if (first.startsWith('SELECT') || first.startsWith('PRAGMA')) {
|
|
253
|
+
return stmt.all(...args);
|
|
254
|
+
}
|
|
255
|
+
const info = stmt.run(...args);
|
|
256
|
+
// Any DML on kv/records is announced generically so views can
|
|
257
|
+
// refresh; more specific writes go through set/patch/append.
|
|
258
|
+
fire('query', 'kv', '*');
|
|
259
|
+
return { changes: info.changes, lastInsertRowid: Number(info.lastInsertRowid) };
|
|
260
|
+
},
|
|
261
|
+
|
|
262
|
+
/** Direct DatabaseSync handle for callers that know what they need. */
|
|
263
|
+
get db() { return db; },
|
|
264
|
+
/** Absolute path to the DB file on disk. */
|
|
265
|
+
get path() { return dbPath; },
|
|
266
|
+
|
|
267
|
+
/** Test hook — closes the underlying handle. Flushes any pending
|
|
268
|
+
* emit events synchronously so tests can observe them without a wait. */
|
|
269
|
+
close() {
|
|
270
|
+
flushPending();
|
|
271
|
+
db.close();
|
|
272
|
+
_handles.delete(pluginName);
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function safeParse(text) {
|
|
278
|
+
try { return JSON.parse(text); } catch { return text; }
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Test helper — reset all cached handles. Called by tests between cases so
|
|
283
|
+
* a temp $HOME override actually takes effect.
|
|
284
|
+
*/
|
|
285
|
+
export function _resetForTests() {
|
|
286
|
+
for (const h of _handles.values()) {
|
|
287
|
+
try { h.db.close(); } catch { /* ignore */ }
|
|
288
|
+
}
|
|
289
|
+
_handles.clear();
|
|
290
|
+
}
|
package/src/terminal/agents.mjs
CHANGED
|
@@ -113,7 +113,7 @@ const TOOL_ALIASES = new Map([
|
|
|
113
113
|
['grep', 'search_code'],
|
|
114
114
|
]);
|
|
115
115
|
|
|
116
|
-
function canonicalToolName(value) {
|
|
116
|
+
export function canonicalToolName(value) {
|
|
117
117
|
const key = String(value || '').trim().toLowerCase();
|
|
118
118
|
return TOOL_ALIASES.get(key) || key;
|
|
119
119
|
}
|
|
@@ -133,7 +133,7 @@ function normalizeScopedArgs(toolName, args = {}, { projectRoot = null } = {}) {
|
|
|
133
133
|
return next;
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
-
function createScopedToolExecutor(baseExecutor, agent, { projectRoot = null } = {}) {
|
|
136
|
+
export function createScopedToolExecutor(baseExecutor, agent, { projectRoot = null } = {}) {
|
|
137
137
|
const tools = Array.isArray(agent.tools) ? agent.tools : [];
|
|
138
138
|
const allowed = new Set(tools.map(canonicalToolName).filter(Boolean));
|
|
139
139
|
if (!allowed.size) return baseExecutor;
|
|
@@ -152,7 +152,11 @@ function createScopedToolExecutor(baseExecutor, agent, { projectRoot = null } =
|
|
|
152
152
|
baseExecutor,
|
|
153
153
|
toolName,
|
|
154
154
|
normalizeScopedArgs(toolName, args, { projectRoot }),
|
|
155
|
-
|
|
155
|
+
{
|
|
156
|
+
...options,
|
|
157
|
+
internal: true,
|
|
158
|
+
subAgent: agent.slug || agent.command || agent.name || true,
|
|
159
|
+
},
|
|
156
160
|
);
|
|
157
161
|
},
|
|
158
162
|
};
|
|
@@ -163,6 +167,22 @@ export function findBuiltinAgent(agentName) {
|
|
|
163
167
|
return BUILTIN_AGENTS.find(agent => agent.command === target || agent.name.toLowerCase() === target) || null;
|
|
164
168
|
}
|
|
165
169
|
|
|
170
|
+
/**
|
|
171
|
+
* Search for an agent across built-in agents, project .bahulam/agents, and plugin agents.
|
|
172
|
+
* @param {string} agentName
|
|
173
|
+
* @param {object} [agentLoader] - Instance of AgentLoader with loadFromPlugins() called
|
|
174
|
+
* @returns {object|null}
|
|
175
|
+
*/
|
|
176
|
+
export function findAgent(agentName, agentLoader = null) {
|
|
177
|
+
const builtin = findBuiltinAgent(agentName);
|
|
178
|
+
if (builtin) return builtin;
|
|
179
|
+
if (agentLoader) {
|
|
180
|
+
const local = agentLoader.get(agentName);
|
|
181
|
+
if (local) return local;
|
|
182
|
+
}
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
|
|
166
186
|
export function localAgentMatches(agent, target) {
|
|
167
187
|
const needle = String(target || '').trim().toLowerCase();
|
|
168
188
|
if (!needle) return false;
|
|
@@ -301,6 +321,7 @@ export async function runAgentDefinition(agentDefinition, instruction, ctx, sess
|
|
|
301
321
|
token: creds.token,
|
|
302
322
|
toolExecutor,
|
|
303
323
|
approvalManager: agentApproval,
|
|
324
|
+
pluginRegistry: options.pluginRegistry || ctx.pluginRegistry || null,
|
|
304
325
|
});
|
|
305
326
|
|
|
306
327
|
session.turns++;
|
|
@@ -344,7 +365,8 @@ export async function runAgentDefinition(agentDefinition, instruction, ctx, sess
|
|
|
344
365
|
* @param {Function} renderEvent - Event renderer function
|
|
345
366
|
*/
|
|
346
367
|
export async function runAgent(agentName, instruction, ctx, session, renderEvent) {
|
|
347
|
-
const
|
|
368
|
+
const agentLoader = ctx?.agentLoader || null;
|
|
369
|
+
const agent = findAgent(agentName, agentLoader);
|
|
348
370
|
if (!agent) {
|
|
349
371
|
process.stderr.write(` ${c.red('Unknown agent: ' + agentName)}\n`);
|
|
350
372
|
return;
|
package/src/terminal/init.mjs
CHANGED
|
@@ -28,7 +28,7 @@ Format v1. Markdown files are intentionally hand-editable.
|
|
|
28
28
|
'config.json': JSON.stringify({
|
|
29
29
|
version: 1,
|
|
30
30
|
context: {
|
|
31
|
-
loadEveryTurn: ['
|
|
31
|
+
loadEveryTurn: ['BAHULAM.md', 'project.md', 'style.md', 'goal.md', 'plan.md', 'tasks/*.md'],
|
|
32
32
|
showReloadNotice: true,
|
|
33
33
|
},
|
|
34
34
|
planning: { owner: 'auto', onUserEditedPlan: 'prefer_user_plan' },
|
|
@@ -57,7 +57,7 @@ Format v1. Markdown files are intentionally hand-editable.
|
|
|
57
57
|
Stop: [],
|
|
58
58
|
},
|
|
59
59
|
}, null, 2) + '\n',
|
|
60
|
-
'
|
|
60
|
+
'BAHULAM.md': `# Project
|
|
61
61
|
|
|
62
62
|
## Quick Facts
|
|
63
63
|
- Stack:
|