@bahulam/code 0.1.16 → 0.1.17

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.
@@ -27,6 +27,37 @@ function normalizeToolNames(value) {
27
27
  }).filter(Boolean);
28
28
  }
29
29
 
30
+ function normalizePathList(value) {
31
+ if (typeof value === 'string' && value.trim()) return [value.trim()];
32
+ if (Array.isArray(value)) {
33
+ return value.map(item => String(item || '').trim()).filter(Boolean);
34
+ }
35
+ return [];
36
+ }
37
+
38
+ function addAgent(agents, seen, agent) {
39
+ if (!agent?.slug) return;
40
+ const key = String(agent.slug).trim().toLowerCase();
41
+ if (!key || seen.has(key)) return;
42
+ seen.add(key);
43
+ agents.push(agent);
44
+ }
45
+
46
+ function normalizeViews(value) {
47
+ return Array.isArray(value) ? value.filter(view => view && typeof view === 'object') : [];
48
+ }
49
+
50
+ function normalizeWorkspaceDeclaration(value) {
51
+ if (typeof value === 'string' && value.trim()) {
52
+ return { agentPath: value.trim(), views: [] };
53
+ }
54
+ if (value && typeof value === 'object') {
55
+ const agentPath = String(value.agent || value.file || value.source || '').trim();
56
+ return { agentPath, views: normalizeViews(value.views) };
57
+ }
58
+ return { agentPath: '', views: [] };
59
+ }
60
+
30
61
  function loadAgentFile(agentDef, pluginDir) {
31
62
  const file = String(agentDef.file || agentDef.handler || '').trim();
32
63
  if (!file || !pluginDir) return {};
@@ -45,21 +76,36 @@ function loadAgentFile(agentDef, pluginDir) {
45
76
  }
46
77
 
47
78
  function normalizeAgentDef(agentDef, pluginName, pluginDir) {
48
- const fileConfig = loadAgentFile(agentDef, pluginDir);
79
+ const loadedConfig = loadAgentFile(agentDef, pluginDir);
80
+ const hasLoadedConfig = loadedConfig && Object.keys(loadedConfig).length > 0;
81
+ const fileConfig = hasLoadedConfig ? loadedConfig : (agentDef || {});
49
82
  const metadata = fileConfig.metadata || fileConfig.meta || {};
50
- const agent = fileConfig.agent || fileConfig.spec?.agent || {};
83
+ const agent = fileConfig.agent || fileConfig.config?.agent || {};
51
84
  const fileTools = (
52
85
  fileConfig.tools
53
- || fileConfig.spec?.tools
86
+ || fileConfig.config?.tools
54
87
  || agent.tools
55
88
  || []
56
89
  );
57
90
  const inlineTools = normalizeToolNames(agentDef.tools);
91
+ const slug = (
92
+ agentDef.slug
93
+ || metadata.slug
94
+ || fileConfig.slug
95
+ || agent.slug
96
+ || agentDef.id
97
+ || metadata.name
98
+ || fileConfig.name
99
+ || agentDef.name
100
+ || metadata.role
101
+ || fileConfig.role
102
+ || ''
103
+ );
58
104
 
59
105
  return {
60
- slug: agentDef.slug || metadata.slug || fileConfig.slug || metadata.name || fileConfig.name || agentDef.name || '',
61
- name: agentDef.name || metadata.name || fileConfig.name || agentDef.slug || '',
62
- description: agentDef.description || metadata.description || fileConfig.description || '',
106
+ slug,
107
+ name: agentDef.name || metadata.name || fileConfig.name || agent.name || slug || '',
108
+ description: agentDef.description || metadata.description || fileConfig.description || agent.description || '',
63
109
  role: agentDef.role || metadata.role || fileConfig.role || 'specialist',
64
110
  system_prompt: (
65
111
  agentDef.system_prompt
@@ -77,12 +123,39 @@ function normalizeAgentDef(agentDef, pluginName, pluginDir) {
77
123
  models: agentDef.models || agent.models || fileConfig.models || undefined,
78
124
  max_tokens: agentDef.max_tokens || agent.max_tokens || fileConfig.max_tokens || undefined,
79
125
  max_iterations: agentDef.max_iterations || agent.max_iterations || fileConfig.max_iterations || undefined,
126
+ disallowed_tools: metadata.disallowedTools || metadata.disallowed_tools || fileConfig.disallowedTools || fileConfig.disallowed_tools || [],
127
+ can_delegate: agentDef.can_delegate ?? agent.can_delegate ?? fileConfig.can_delegate ?? false,
128
+ can_be_delegated_to: agentDef.can_be_delegated_to ?? agent.can_be_delegated_to ?? fileConfig.can_be_delegated_to ?? true,
129
+ apiVersion: fileConfig.apiVersion || fileConfig.api_version || undefined,
130
+ kind: fileConfig.kind || undefined,
80
131
  file: agentDef.file || agentDef.handler || '',
81
132
  source: `plugin:${pluginName}`,
82
133
  source_scope: 'plugin',
83
134
  };
84
135
  }
85
136
 
137
+ function loadAgentPath(agentPath, pluginDir, pluginName, label) {
138
+ if (!agentPath || !pluginDir) return null;
139
+ const fullPath = path.resolve(pluginDir, agentPath);
140
+ try {
141
+ if (!fs.existsSync(fullPath) || !fs.statSync(fullPath).isFile()) return null;
142
+ const agentDef = parseYaml(fs.readFileSync(fullPath, 'utf-8'));
143
+ if (!agentDef || typeof agentDef !== 'object') {
144
+ console.warn(`Skipping ${label} ${fullPath}: not a mapping`);
145
+ return null;
146
+ }
147
+ const agent = normalizeAgentDef(agentDef, pluginName, pluginDir);
148
+ if (!agent.slug) {
149
+ console.warn(`Skipping ${label} ${fullPath}: no slug`);
150
+ return null;
151
+ }
152
+ return agent;
153
+ } catch (err) {
154
+ console.warn(`Failed to load ${label} ${fullPath}: ${err.message}`);
155
+ return null;
156
+ }
157
+ }
158
+
86
159
  /**
87
160
  * Parse a plugin manifest from YAML text.
88
161
  * @param {string} yamlText - Raw YAML content
@@ -141,8 +214,8 @@ export function normalizeManifest(raw, source = '') {
141
214
  }
142
215
 
143
216
  const meta = raw.metadata || raw.meta || {};
144
- const spec = raw.spec || raw.plugin || {};
145
- const name = meta.name || spec.name || '';
217
+ const config = raw.config || raw.plugin || {};
218
+ const name = meta.name || config.name || '';
146
219
  if (!name) {
147
220
  if (process.env.DEBUG) {
148
221
  console.warn(`Plugin manifest missing name: ${source}`);
@@ -152,15 +225,62 @@ export function normalizeManifest(raw, source = '') {
152
225
 
153
226
  // Normalize agents
154
227
  const agents = [];
228
+ const agentSlugs = new Set();
155
229
  const pluginDir = source ? path.dirname(source) : '';
156
- for (const agentDef of (spec.agents || [])) {
157
- const agent = normalizeAgentDef(agentDef, name, pluginDir);
158
- if (agent.slug) agents.push(agent);
230
+ for (const agentDef of (config.agents || [])) {
231
+ addAgent(agents, agentSlugs, normalizeAgentDef(agentDef, name, pluginDir));
232
+ }
233
+ const workspaceDecl = normalizeWorkspaceDeclaration(config.workspace);
234
+ if (workspaceDecl.agentPath) {
235
+ const workspaceAgent = loadAgentPath(workspaceDecl.agentPath, pluginDir, name, 'workspace');
236
+ if (workspaceAgent) {
237
+ workspaceAgent.entry_agent = true;
238
+ addAgent(agents, agentSlugs, workspaceAgent);
239
+ }
240
+ }
241
+ // Optional authoring convenience: `config.agents_from: <dir|string[]>`.
242
+ // This is for delegated sub-agents. The primary/entry agent should
243
+ // live at `config.workspace: ./config/workspace.yaml`.
244
+ const agentsFrom = normalizePathList(config.agents_from);
245
+ for (const agentsFromPath of agentsFrom) {
246
+ if (!pluginDir) continue;
247
+ const agentsDir = path.resolve(pluginDir, agentsFromPath);
248
+ try {
249
+ if (fs.existsSync(agentsDir) && fs.statSync(agentsDir).isDirectory()) {
250
+ const files = fs.readdirSync(agentsDir)
251
+ .filter(f => /\.(ya?ml)$/i.test(f))
252
+ .sort();
253
+ for (const f of files) {
254
+ const filePath = path.join(agentsDir, f);
255
+ let agentDef;
256
+ try {
257
+ agentDef = parseYaml(fs.readFileSync(filePath, 'utf-8'));
258
+ } catch (err) {
259
+ console.warn(`Failed to parse plugin agent file ${filePath}: ${err.message}`);
260
+ continue;
261
+ }
262
+ if (!agentDef || typeof agentDef !== 'object') {
263
+ console.warn(`Skipping plugin agent file ${filePath}: not a mapping`);
264
+ continue;
265
+ }
266
+ const agent = normalizeAgentDef(agentDef, name, pluginDir);
267
+ if (!agent.slug) {
268
+ console.warn(`Skipping plugin agent file ${filePath}: no slug`);
269
+ continue;
270
+ }
271
+ addAgent(agents, agentSlugs, agent);
272
+ }
273
+ }
274
+ } catch (err) {
275
+ if (process.env.DEBUG) {
276
+ console.error(`Failed to load agents_from ${agentsDir}: ${err.message}`);
277
+ }
278
+ }
159
279
  }
160
280
 
161
281
  // Normalize tools
162
282
  const tools = [];
163
- for (const toolDef of (spec.tools || [])) {
283
+ for (const toolDef of (config.tools || [])) {
164
284
  const tool = {
165
285
  name: toolDef.name || '',
166
286
  description: toolDef.description || '',
@@ -171,11 +291,12 @@ export function normalizeManifest(raw, source = '') {
171
291
  if (tool.name) tools.push(tool);
172
292
  }
173
293
 
174
- // Normalize workspace
175
- const workspace = spec.workspace || {};
176
- if (workspace.views && !Array.isArray(workspace.views)) {
177
- workspace.views = [];
178
- }
294
+ // Normalize browser workspace views. New manifests use `config.views`.
295
+ // Older installed manifests with `config.workspace.views` still render.
296
+ const views = [
297
+ ...workspaceDecl.views,
298
+ ...normalizeViews(config.views),
299
+ ];
179
300
 
180
301
  // Normalize MCP servers — the Plugin=MCP+UX story. Two sources are
181
302
  // merged so authors can either:
@@ -185,8 +306,8 @@ export function normalizeManifest(raw, source = '') {
185
306
  // transfers with zero edits)
186
307
  // Inline wins on name collision so authors can override a portable
187
308
  // config for the local plugin without editing mcp.json.
188
- const mcpServers = _readMcpServers(spec.mcpServers, source);
189
- const composes = normalizeComposes(spec.composes);
309
+ const mcpServers = _readMcpServers(config.mcpServers, source);
310
+ const composes = normalizeComposes(config.composes);
190
311
 
191
312
  return {
192
313
  apiVersion,
@@ -198,10 +319,12 @@ export function normalizeManifest(raw, source = '') {
198
319
  author: meta.author || '',
199
320
  repository: meta.repository || '',
200
321
  },
201
- spec: {
322
+ config: {
202
323
  tools,
203
324
  agents,
204
- workspace,
325
+ ...(agentsFrom.length ? { agents_from: agentsFrom } : {}),
326
+ workspace: workspaceDecl.agentPath,
327
+ views,
205
328
  mcpServers,
206
329
  composes,
207
330
  },
@@ -277,12 +400,12 @@ export function validatePluginManifest(manifest) {
277
400
  errors.push('Plugin metadata.name is required');
278
401
  }
279
402
 
280
- if (manifest.spec) {
281
- for (const tool of (manifest.spec.tools || [])) {
403
+ if (manifest.config) {
404
+ for (const tool of (manifest.config.tools || [])) {
282
405
  if (!tool.name) errors.push('Tool missing name');
283
406
  if (!tool.tool) errors.push(`Tool "${tool.name || '(unnamed)'}" missing tool module path (tool: ./tools/<name>.mjs)`);
284
407
  }
285
- for (const agent of (manifest.spec.agents || [])) {
408
+ for (const agent of (manifest.config.agents || [])) {
286
409
  if (!agent.slug && !agent.name) errors.push('Agent missing slug or name');
287
410
  }
288
411
  }
@@ -77,11 +77,22 @@ function migratePeersForPi(pkgPath) {
77
77
  let pkg = {};
78
78
  try { pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); } catch { return {}; }
79
79
  const peers = pkg.peerDependencies || {};
80
- if (!Object.keys(peers).length) return pkg;
81
80
  const merged = { ...(pkg.dependencies || {}) };
81
+ let changed = false;
82
82
  for (const [name, range] of Object.entries(peers)) {
83
- if (!merged[name]) merged[name] = range === '*' ? 'latest' : range;
83
+ if (!merged[name]) {
84
+ merged[name] = range === '*' ? 'latest' : range;
85
+ changed = true;
86
+ }
84
87
  }
88
+ // Some current pi packages import the Pi server runtime transitively
89
+ // through @earendil-works/pi-coding-agent without declaring it. Materialize
90
+ // it here so discovery can import the extension and capture tools.
91
+ if (merged['@earendil-works/pi-coding-agent'] && !merged['@earendil-works/pi-server']) {
92
+ merged['@earendil-works/pi-server'] = 'latest';
93
+ changed = true;
94
+ }
95
+ if (!changed && !Object.keys(peers).length) return pkg;
85
96
  const rewritten = { ...pkg, dependencies: merged };
86
97
  delete rewritten.peerDependencies;
87
98
  fs.writeFileSync(pkgPath, JSON.stringify(rewritten, null, 2));
@@ -185,7 +185,7 @@ function generatePrompt(packageName, namespace, toolNames, hasState, requirement
185
185
  * (quoted keys, over-escaping); a small emitter here yields a diff-
186
186
  * friendly manifest the user can edit.
187
187
  */
188
- function renderManifest({ slug, packageName, versionRange, namespace, exposeTools, agentSlug, agentDescription, hasState, hasWorkspace, systemPrompt }) {
188
+ function renderManifest({ slug, packageName, versionRange, namespace, exposeTools, hasState, hasWorkspace }) {
189
189
  const versionSpec = versionRange ? `${packageName}@${versionRange}` : packageName;
190
190
  const tools = hasState ? [
191
191
  ' tools:',
@@ -235,30 +235,11 @@ function renderManifest({ slug, packageName, versionRange, namespace, exposeTool
235
235
  '',
236
236
  ];
237
237
 
238
- const agentToolRefs = [
239
- ...(hasState ? ['save_item', 'list_items', 'drop_item'] : []),
240
- ...exposeTools.map(t => `${namespace}${COMPOSED_TOOL_SEPARATOR}${t}`),
241
- ];
242
-
243
- const agentBlock = [
244
- ' agents:',
245
- ` - slug: ${agentSlug}`,
246
- ` name: ${yamlString(agentSlug.replace(/-/g, ' '))}`,
247
- ' role: specialist',
248
- ' description: >',
249
- ` ${agentDescription}`,
250
- ' tools:',
251
- ...agentToolRefs.map(t => ` - ${t}`),
252
- ` system_prompt: ${yamlBlock(systemPrompt, 8)}`,
253
- '',
254
- ];
255
-
256
238
  const workspaceBlock = hasWorkspace ? [
257
- ' workspace:',
258
- ' views:',
259
- ' - type: panel',
260
- ` name: ${yamlString(slug.replace(/-/g, ' '))}`,
261
- ' source: ./workspace/panel.html',
239
+ ' views:',
240
+ ' - type: panel',
241
+ ` name: ${yamlString(slug.replace(/-/g, ' '))}`,
242
+ ' source: ./workspace/panel.html',
262
243
  '',
263
244
  ] : [];
264
245
 
@@ -272,14 +253,41 @@ function renderManifest({ slug, packageName, versionRange, namespace, exposeTool
272
253
  ` Auto-scaffolded pack composing pi:${packageName}.`,
273
254
  ` Edit tools/, workspace/, and this manifest to customize.`,
274
255
  '',
275
- 'spec:',
256
+ 'config:',
276
257
  ...tools,
277
258
  ...composesBlock,
278
- ...agentBlock,
259
+ ' workspace: ./config/workspace.yaml',
260
+ '',
279
261
  ...workspaceBlock,
280
262
  ].join('\n');
281
263
  }
282
264
 
265
+ function renderAgentFile({ namespace, exposeTools, agentSlug, agentDescription, hasState, systemPrompt }) {
266
+ const agentToolRefs = [
267
+ ...(hasState ? ['save_item', 'list_items', 'drop_item'] : []),
268
+ ...exposeTools.map(t => `${namespace}${COMPOSED_TOOL_SEPARATOR}${t}`),
269
+ ];
270
+
271
+ return [
272
+ '# config/workspace.yaml — entry agent loaded when plugin.yaml declares',
273
+ '# config.workspace: ./config/workspace.yaml.',
274
+ 'apiVersion: agent.framework/v1',
275
+ 'kind: SingleAgent',
276
+ 'metadata:',
277
+ ` slug: ${agentSlug}`,
278
+ ` name: ${yamlString(agentSlug.replace(/-/g, ' '))}`,
279
+ ' role: specialist',
280
+ ' description: >',
281
+ ` ${agentDescription}`,
282
+ 'agent:',
283
+ ' max_iterations: 10',
284
+ ` system_prompt: ${yamlBlock(systemPrompt, 4)}`,
285
+ 'tools:',
286
+ ...agentToolRefs.map(t => ` - ${t}`),
287
+ '',
288
+ ].join('\n');
289
+ }
290
+
283
291
  const SAVE_ITEM_TOOL = `/**
284
292
  * save_item — persist a single item to the pack's notebook.
285
293
  * Append-style records so a topic can accumulate many entries over time.
@@ -537,14 +545,22 @@ export function scaffoldPiPack({
537
545
  versionRange,
538
546
  namespace,
539
547
  exposeTools: toolNames,
540
- agentSlug,
541
- agentDescription,
542
548
  hasState: state,
543
549
  hasWorkspace: workspace,
544
- systemPrompt,
545
550
  });
546
551
  fs.writeFileSync(path.join(dest, 'plugin.yaml'), manifest);
547
552
 
553
+ const configDir = path.join(dest, 'config');
554
+ fs.mkdirSync(configDir, { recursive: true });
555
+ fs.writeFileSync(path.join(configDir, 'workspace.yaml'), renderAgentFile({
556
+ namespace,
557
+ exposeTools: toolNames,
558
+ agentSlug,
559
+ agentDescription,
560
+ hasState: state,
561
+ systemPrompt,
562
+ }));
563
+
548
564
  if (state) {
549
565
  const toolsDir = path.join(dest, 'tools');
550
566
  fs.mkdirSync(toolsDir, { recursive: true });
@@ -18,11 +18,11 @@
18
18
  */
19
19
 
20
20
  import * as fs from 'node:fs';
21
- import * as os from 'node:os';
22
21
  import * as path from 'node:path';
23
22
  import { pathToFileURL } from 'node:url';
24
23
  import { parsePluginManifestFile, validatePluginManifest } from './manifest.mjs';
25
24
  import { composedToolName, validateCompose } from './pi-compose.mjs';
25
+ import { bahulamHome } from '../core/paths.mjs';
26
26
 
27
27
  const TOOL_NAME_RE = /^[A-Za-z_][A-Za-z0-9_-]{0,63}$/;
28
28
  const AGENT_SLUG_RE = /^[a-z][a-z0-9-]{0,63}$/;
@@ -84,12 +84,21 @@ export async function preflightPlugin(pluginDir, opts = {}) {
84
84
  warnings.push(`metadata.name "${name}" should be lowercase kebab-case for registry compatibility`);
85
85
  }
86
86
 
87
- const tools = manifest.spec?.tools || [];
88
- const agents = manifest.spec?.agents || [];
89
- const views = manifest.spec?.workspace?.views || [];
90
- const mcpServers = manifest.spec?.mcpServers || {};
87
+ const tools = manifest.config?.tools || [];
88
+ const agents = manifest.config?.agents || [];
89
+ const workspacePath = manifest.config?.workspace || '';
90
+ const views = manifest.config?.views || [];
91
+ const mcpServers = manifest.config?.mcpServers || {};
91
92
  const mcpServerNames = new Set(Object.keys(mcpServers));
92
- const composes = manifest.spec?.composes || [];
93
+ const composes = manifest.config?.composes || [];
94
+
95
+ if (workspacePath) {
96
+ const full = path.resolve(pluginDir, workspacePath);
97
+ const inside = full === pluginDir || full.startsWith(pluginDir + path.sep);
98
+ if (!inside) errors.push(`Workspace entry agent path escapes the plugin directory: ${workspacePath}`);
99
+ else if (!fs.existsSync(full)) errors.push(`Workspace entry agent not found: ${workspacePath}`);
100
+ else if (!fs.statSync(full).isFile()) errors.push(`Workspace entry agent is not a file: ${workspacePath}`);
101
+ }
93
102
 
94
103
  // MCP server sanity — every server should have EITHER command (stdio)
95
104
  // OR url (remote). Anything else is meaningless config.
@@ -240,7 +249,7 @@ export function existingInstalledNames(cwd = process.cwd()) {
240
249
  const names = [];
241
250
  for (const dir of [
242
251
  path.join(cwd, '.bahulam', 'plugins'),
243
- path.join(os.homedir(), '.bahulam', 'plugins'),
252
+ path.join(bahulamHome(), 'plugins'),
244
253
  ]) {
245
254
  if (!fs.existsSync(dir)) continue;
246
255
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
@@ -7,13 +7,13 @@
7
7
 
8
8
  import fs from 'fs';
9
9
  import path from 'path';
10
- import os from 'os';
11
10
  import { parsePluginManifestFile, validatePluginManifest } from './manifest.mjs';
12
11
  import { expandComposedTools } from './pi-compose.mjs';
12
+ import { bahulamHome } from '../core/paths.mjs';
13
13
 
14
14
  const DEFAULT_PLUGIN_DIRS = () => [
15
15
  path.join(process.cwd(), '.bahulam', 'plugins'),
16
- path.join(os.homedir(), '.bahulam', 'plugins'),
16
+ path.join(bahulamHome(), 'plugins'),
17
17
  ];
18
18
 
19
19
  export class PluginRegistry {
@@ -153,7 +153,7 @@ export class PluginRegistry {
153
153
  listTools() {
154
154
  const tools = [];
155
155
  for (const plugin of this.plugins.values()) {
156
- for (const tool of (plugin.spec?.tools || [])) {
156
+ for (const tool of (plugin.config?.tools || [])) {
157
157
  tools.push({
158
158
  ...tool,
159
159
  _plugin_name: plugin.metadata?.name,
@@ -163,7 +163,7 @@ export class PluginRegistry {
163
163
  tools.push(...expandComposedTools(
164
164
  plugin.metadata?.name || '',
165
165
  plugin._dir,
166
- plugin.spec?.composes || [],
166
+ plugin.config?.composes || [],
167
167
  ));
168
168
  }
169
169
  return tools;
@@ -183,7 +183,7 @@ export class PluginRegistry {
183
183
  listMcpServers() {
184
184
  const out = [];
185
185
  for (const plugin of this.plugins.values()) {
186
- const servers = plugin.spec?.mcpServers || {};
186
+ const servers = plugin.config?.mcpServers || {};
187
187
  const pluginName = plugin.metadata?.name || '';
188
188
  for (const [name, config] of Object.entries(servers)) {
189
189
  if (config && typeof config === 'object') {
@@ -201,7 +201,7 @@ export class PluginRegistry {
201
201
  listAgents() {
202
202
  const agents = [];
203
203
  for (const plugin of this.plugins.values()) {
204
- for (const agent of (plugin.spec?.agents || [])) {
204
+ for (const agent of (plugin.config?.agents || [])) {
205
205
  agents.push({
206
206
  ...agent,
207
207
  _plugin_name: plugin.metadata?.name,
@@ -33,7 +33,10 @@
33
33
  import fs from 'node:fs';
34
34
  import os from 'node:os';
35
35
  import path from 'node:path';
36
- import { DatabaseSync } from 'node:sqlite';
36
+ import { createRequire } from 'node:module';
37
+
38
+ const require = createRequire(import.meta.url);
39
+ let DatabaseSync = null;
37
40
 
38
41
  // Silence the single "SQLite is an experimental feature" warning that
39
42
  // node:sqlite emits at first import. Users would see it on every plugin
@@ -49,6 +52,14 @@ import { DatabaseSync } from 'node:sqlite';
49
52
  };
50
53
  }
51
54
 
55
+ if (process.env.BAHULAM_PLUGIN_STATE_BACKEND !== 'json') {
56
+ try {
57
+ ({ DatabaseSync } = require('node:sqlite'));
58
+ } catch {
59
+ DatabaseSync = null;
60
+ }
61
+ }
62
+
52
63
  const DATA_ROOT = () => path.join(os.homedir(), '.bahulam', 'data');
53
64
  const PLUGIN_NAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
54
65
  const DEBOUNCE_MS = 50;
@@ -58,6 +69,134 @@ const DEBOUNCE_MS = 50;
58
69
  // for the lifetime of the CLI; explicit close() is available for tests.
59
70
  const _handles = new Map(); // pluginName -> { db, dir, path }
60
71
 
72
+ class JsonStatement {
73
+ constructor(db, sql) {
74
+ this.db = db;
75
+ this.sql = String(sql || '').trim().replace(/\s+/g, ' ').toUpperCase();
76
+ }
77
+
78
+ get(...args) {
79
+ if (this.sql === 'SELECT VALUE FROM KV WHERE KEY = ?') {
80
+ const key = String(args[0]);
81
+ return this.db.store.kv[key] ? { value: this.db.store.kv[key].value } : undefined;
82
+ }
83
+ throw new Error('Raw SELECT is only available with native node:sqlite');
84
+ }
85
+
86
+ all(...args) {
87
+ if (this.sql === 'SELECT KEY FROM KV ORDER BY KEY') {
88
+ return Object.keys(this.db.store.kv).sort().map(key => ({ key }));
89
+ }
90
+ if (this.sql === 'SELECT ID, PAYLOAD, CREATED_AT FROM RECORDS WHERE STREAM = ? ORDER BY ID ASC LIMIT ?') {
91
+ return this.db.records(String(args[0]), Number(args[1]), 'asc');
92
+ }
93
+ if (this.sql === 'SELECT ID, PAYLOAD, CREATED_AT FROM RECORDS WHERE STREAM = ? ORDER BY ID DESC LIMIT ?') {
94
+ return this.db.records(String(args[0]), Number(args[1]), 'desc');
95
+ }
96
+ if (this.sql === 'SELECT COUNT(*) AS N FROM RECORDS WHERE STREAM = ?') {
97
+ return [{ n: this.db.store.records.filter(row => row.stream === String(args[0])).length }];
98
+ }
99
+ throw new Error('Raw SELECT is only available with native node:sqlite');
100
+ }
101
+
102
+ run(...args) {
103
+ if (this.sql.startsWith('INSERT INTO KV(')) {
104
+ const [key, value, updated_at] = args;
105
+ this.db.store.kv[String(key)] = { value: String(value), updated_at: String(updated_at) };
106
+ this.db.save();
107
+ return { changes: 1, lastInsertRowid: 0 };
108
+ }
109
+ if (this.sql === 'DELETE FROM KV WHERE KEY = ?') {
110
+ const key = String(args[0]);
111
+ const existed = Object.prototype.hasOwnProperty.call(this.db.store.kv, key);
112
+ if (existed) {
113
+ delete this.db.store.kv[key];
114
+ this.db.save();
115
+ }
116
+ return { changes: existed ? 1 : 0, lastInsertRowid: 0 };
117
+ }
118
+ if (this.sql === 'INSERT INTO RECORDS(STREAM, PAYLOAD, CREATED_AT) VALUES(?, ?, ?)') {
119
+ const row = {
120
+ id: this.db.store.nextRecordId++,
121
+ stream: String(args[0]),
122
+ payload: String(args[1]),
123
+ created_at: String(args[2]),
124
+ };
125
+ this.db.store.records.push(row);
126
+ this.db.save();
127
+ return { changes: 1, lastInsertRowid: row.id };
128
+ }
129
+ if (this.sql === 'DELETE FROM RECORDS WHERE STREAM = ?') {
130
+ const stream = String(args[0]);
131
+ const before = this.db.store.records.length;
132
+ this.db.store.records = this.db.store.records.filter(row => row.stream !== stream);
133
+ const changes = before - this.db.store.records.length;
134
+ if (changes) this.db.save();
135
+ return { changes, lastInsertRowid: 0 };
136
+ }
137
+ if (this.sql === 'DELETE FROM RECORDS WHERE STREAM = ? AND ID = ?') {
138
+ const stream = String(args[0]);
139
+ const id = Number(args[1]);
140
+ const before = this.db.store.records.length;
141
+ this.db.store.records = this.db.store.records.filter(row => !(row.stream === stream && row.id === id));
142
+ const changes = before - this.db.store.records.length;
143
+ if (changes) this.db.save();
144
+ return { changes, lastInsertRowid: 0 };
145
+ }
146
+ throw new Error('Raw DML is only available with native node:sqlite');
147
+ }
148
+ }
149
+
150
+ class JsonStateDb {
151
+ constructor(dbPath) {
152
+ this.path = dbPath;
153
+ this.store = {
154
+ kv: {},
155
+ records: [],
156
+ nextRecordId: 1,
157
+ };
158
+ try {
159
+ if (fs.existsSync(dbPath)) {
160
+ const parsed = JSON.parse(fs.readFileSync(dbPath, 'utf-8'));
161
+ if (parsed && typeof parsed === 'object') {
162
+ this.store = {
163
+ kv: parsed.kv && typeof parsed.kv === 'object' ? parsed.kv : {},
164
+ records: Array.isArray(parsed.records) ? parsed.records : [],
165
+ nextRecordId: Number(parsed.nextRecordId) || 1,
166
+ };
167
+ }
168
+ } else {
169
+ this.save();
170
+ }
171
+ } catch {
172
+ this.save();
173
+ }
174
+ }
175
+
176
+ exec() {}
177
+
178
+ prepare(sql) {
179
+ return new JsonStatement(this, sql);
180
+ }
181
+
182
+ records(stream, limit, order) {
183
+ const cap = Math.max(1, Math.min(10000, Math.floor(limit) || 50));
184
+ const rows = this.store.records
185
+ .filter(row => row.stream === stream)
186
+ .sort((a, b) => order === 'asc' ? a.id - b.id : b.id - a.id)
187
+ .slice(0, cap);
188
+ return rows.map(row => ({ id: row.id, payload: row.payload, created_at: row.created_at }));
189
+ }
190
+
191
+ save() {
192
+ fs.writeFileSync(this.path, JSON.stringify(this.store, null, 2));
193
+ }
194
+
195
+ close() {
196
+ this.save();
197
+ }
198
+ }
199
+
61
200
  function pluginDataDir(pluginName) {
62
201
  if (!PLUGIN_NAME_RE.test(pluginName)) {
63
202
  throw new Error(`invalid plugin name for state dir: ${pluginName}`);
@@ -71,7 +210,7 @@ function openDb(pluginName) {
71
210
  if (_handles.has(pluginName)) return _handles.get(pluginName);
72
211
  const dir = pluginDataDir(pluginName);
73
212
  const dbPath = path.join(dir, 'state.db');
74
- const db = new DatabaseSync(dbPath);
213
+ const db = DatabaseSync ? new DatabaseSync(dbPath) : new JsonStateDb(dbPath);
75
214
  // WAL: multiple readers, one writer; robust against concurrent view+agent.
76
215
  db.exec('PRAGMA journal_mode = WAL');
77
216
  db.exec('PRAGMA synchronous = NORMAL');