@bahulam/code 0.1.20 → 0.1.22
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 +1 -1
- package/src/agents/loader.mjs +1 -0
- package/src/agents/parser.mjs +1 -0
- package/src/agents/registry.mjs +5 -1
- package/src/commands/plugin-manage.mjs +31 -20
- package/src/commands/plugin.mjs +3 -6
- package/src/config/model-catalog-default.json +2 -2
- package/src/config/model-catalog.mjs +2 -0
- package/src/core/paths.mjs +24 -0
- package/src/core/stream-client.mjs +48 -12
- package/src/core/tool-executor.mjs +133 -11
- package/src/local-service/server.mjs +1 -4
- package/src/plugins/manifest.mjs +207 -1
- package/src/plugins/pi-compat/requirements.mjs +67 -1
- package/src/plugins/pi-compat/scaffold.mjs +209 -4
- package/src/plugins/preflight.mjs +28 -7
- package/src/plugins/registry.mjs +12 -9
- package/src/plugins/state-tools.mjs +86 -0
- package/src/plugins/state.mjs +168 -16
- package/src/tools/registry.mjs +7 -1
package/src/plugins/manifest.mjs
CHANGED
|
@@ -35,6 +35,12 @@ function normalizePathList(value) {
|
|
|
35
35
|
return [];
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
function normalizeStringList(value) {
|
|
39
|
+
if (typeof value === 'string' && value.trim()) return [value.trim()];
|
|
40
|
+
if (Array.isArray(value)) return value.map(item => String(item || '').trim()).filter(Boolean);
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
|
|
38
44
|
function addAgent(agents, seen, agent) {
|
|
39
45
|
if (!agent?.slug) return;
|
|
40
46
|
const key = String(agent.slug).trim().toLowerCase();
|
|
@@ -119,6 +125,7 @@ function normalizeAgentDef(agentDef, pluginName, pluginDir) {
|
|
|
119
125
|
|| ''
|
|
120
126
|
),
|
|
121
127
|
tools: inlineTools.length ? inlineTools : normalizeToolNames(fileTools),
|
|
128
|
+
aliases: normalizeStringList(agentDef.aliases || metadata.aliases || fileConfig.aliases || agent.aliases),
|
|
122
129
|
model: agentDef.model || agent.model || fileConfig.model || null,
|
|
123
130
|
models: agentDef.models || agent.models || fileConfig.models || undefined,
|
|
124
131
|
max_tokens: agentDef.max_tokens || agent.max_tokens || fileConfig.max_tokens || undefined,
|
|
@@ -156,6 +163,190 @@ function loadAgentPath(agentPath, pluginDir, pluginName, label) {
|
|
|
156
163
|
}
|
|
157
164
|
}
|
|
158
165
|
|
|
166
|
+
// Identifiers that are safe to interpolate into DDL. Manifests are
|
|
167
|
+
// plugin-author controlled, but a typo (or a malicious registry entry)
|
|
168
|
+
// must never be able to escape the quoted identifier and inject SQL.
|
|
169
|
+
const SAFE_IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
|
|
170
|
+
|
|
171
|
+
// SQLite type affinities. Anything else collapses to TEXT so a bad
|
|
172
|
+
// manifest degrades into a working table instead of a failed CREATE.
|
|
173
|
+
const SQL_TYPES = new Set(['INTEGER', 'TEXT', 'REAL', 'BLOB', 'NUMERIC']);
|
|
174
|
+
|
|
175
|
+
function normalizeSqlType(value) {
|
|
176
|
+
const raw = String(value || '').trim().toUpperCase();
|
|
177
|
+
if (!raw) return 'TEXT';
|
|
178
|
+
// Accept the common aliases rather than rejecting an otherwise-fine manifest.
|
|
179
|
+
if (raw === 'INT' || raw === 'BIGINT') return 'INTEGER';
|
|
180
|
+
if (raw === 'BOOL' || raw === 'BOOLEAN') return 'INTEGER';
|
|
181
|
+
if (raw === 'FLOAT' || raw === 'DOUBLE') return 'REAL';
|
|
182
|
+
if (raw === 'STRING' || raw === 'VARCHAR' || raw === 'DATETIME' || raw === 'TIMESTAMP' || raw === 'JSON') return 'TEXT';
|
|
183
|
+
return SQL_TYPES.has(raw) ? raw : 'TEXT';
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Normalize `config.state` — the manifest-declared state schema plus the
|
|
188
|
+
* agent-visibility contract for that state.
|
|
189
|
+
*
|
|
190
|
+
* Every plugin already gets a SQLite sidecar for free (the `kv` and
|
|
191
|
+
* `records` tables). This block is how a plugin graduates from "a tool
|
|
192
|
+
* that remembers a cursor" to "a local app that owns its own domain
|
|
193
|
+
* tables" (questions + answers + progress for a tutor, documents for a
|
|
194
|
+
* study aid, and so on).
|
|
195
|
+
*
|
|
196
|
+
* Three parts, matching the three visibility tiers:
|
|
197
|
+
*
|
|
198
|
+
* tables DDL applied idempotently whenever the plugin's state
|
|
199
|
+
* DB is opened. Existing columns are never dropped;
|
|
200
|
+
* new columns are added with ALTER TABLE so a version
|
|
201
|
+
* bump never costs the user their data.
|
|
202
|
+
* context_always Small, high-signal slices injected into the agent
|
|
203
|
+
* context every turn (current lesson, progress).
|
|
204
|
+
* context_tools Read-only tools auto-generated for the agent to call
|
|
205
|
+
* on demand for the larger data (list questions).
|
|
206
|
+
*
|
|
207
|
+
* Anything not declared here stays reachable only from the plugin's own
|
|
208
|
+
* handlers via `state.query()` — the third tier.
|
|
209
|
+
*
|
|
210
|
+
* @param {object|null|undefined} value raw `config.state`
|
|
211
|
+
* @returns {{tables: object[], context_always: object[], context_tools: object[]}}
|
|
212
|
+
*/
|
|
213
|
+
function normalizeState(value) {
|
|
214
|
+
const empty = { tables: [], context_always: [], context_tools: [] };
|
|
215
|
+
if (!value || typeof value !== 'object') return empty;
|
|
216
|
+
|
|
217
|
+
const tables = [];
|
|
218
|
+
for (const rawTable of (Array.isArray(value.tables) ? value.tables : [])) {
|
|
219
|
+
if (!rawTable || typeof rawTable !== 'object') continue;
|
|
220
|
+
const name = String(rawTable.name || '').trim();
|
|
221
|
+
if (!SAFE_IDENT_RE.test(name)) {
|
|
222
|
+
console.warn(`Skipping state table with unsafe or missing name: ${JSON.stringify(rawTable.name)}`);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const columns = [];
|
|
227
|
+
const columnNames = new Set();
|
|
228
|
+
for (const rawCol of (Array.isArray(rawTable.columns) ? rawTable.columns : [])) {
|
|
229
|
+
if (!rawCol || typeof rawCol !== 'object') continue;
|
|
230
|
+
const colName = String(rawCol.name || '').trim();
|
|
231
|
+
if (!SAFE_IDENT_RE.test(colName)) {
|
|
232
|
+
console.warn(`Skipping column with unsafe or missing name in table ${name}: ${JSON.stringify(rawCol.name)}`);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (columnNames.has(colName)) continue;
|
|
236
|
+
columnNames.add(colName);
|
|
237
|
+
|
|
238
|
+
// `references` is free-form in the manifest, so validate the shape
|
|
239
|
+
// strictly before it reaches a CREATE TABLE string.
|
|
240
|
+
const references = String(rawCol.references || '').trim();
|
|
241
|
+
const safeReferences = /^[A-Za-z_][A-Za-z0-9_]{0,63}\s*\(\s*[A-Za-z_][A-Za-z0-9_]{0,63}\s*\)$/.test(references)
|
|
242
|
+
? references.replace(/\s+/g, '')
|
|
243
|
+
: null;
|
|
244
|
+
if (references && !safeReferences) {
|
|
245
|
+
console.warn(`Ignoring malformed references "${references}" on ${name}.${colName}`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const primary = rawCol.primary === true || rawCol.primary_key === true || rawCol.primaryKey === true;
|
|
249
|
+
const type = normalizeSqlType(rawCol.type);
|
|
250
|
+
columns.push({
|
|
251
|
+
name: colName,
|
|
252
|
+
type,
|
|
253
|
+
primary,
|
|
254
|
+
// SQLite only allows AUTOINCREMENT on INTEGER PRIMARY KEY.
|
|
255
|
+
autoincrement: (rawCol.autoincrement === true || rawCol.auto_increment === true)
|
|
256
|
+
&& primary && type === 'INTEGER',
|
|
257
|
+
not_null: rawCol.not_null === true || rawCol.notNull === true,
|
|
258
|
+
default: rawCol.default === undefined || rawCol.default === null ? null : String(rawCol.default),
|
|
259
|
+
references: safeReferences,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
if (!columns.length) {
|
|
263
|
+
console.warn(`Skipping state table ${name}: no usable columns`);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const indexes = [];
|
|
268
|
+
for (const rawIndex of (Array.isArray(rawTable.indexes) ? rawTable.indexes : [])) {
|
|
269
|
+
if (!rawIndex || typeof rawIndex !== 'object') continue;
|
|
270
|
+
const source = Array.isArray(rawIndex.columns) ? rawIndex.columns
|
|
271
|
+
: (rawIndex.column ? [rawIndex.column] : []);
|
|
272
|
+
const indexColumns = source
|
|
273
|
+
.map(c => String(c || '').trim())
|
|
274
|
+
.filter(c => SAFE_IDENT_RE.test(c) && columnNames.has(c));
|
|
275
|
+
if (indexColumns.length) {
|
|
276
|
+
indexes.push({ columns: indexColumns, unique: rawIndex.unique === true });
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
tables.push({ name, columns, indexes });
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const tableNames = new Set(tables.map(t => t.name));
|
|
284
|
+
|
|
285
|
+
// Tier 1 — injected every turn. Accept a bare string as a kv key so the
|
|
286
|
+
// common case stays a one-liner in YAML.
|
|
287
|
+
const contextAlways = [];
|
|
288
|
+
for (const rawEntry of (Array.isArray(value.context_always) ? value.context_always : [])) {
|
|
289
|
+
if (typeof rawEntry === 'string') {
|
|
290
|
+
const key = rawEntry.trim();
|
|
291
|
+
if (key) contextAlways.push({ kind: 'kv', key });
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
if (!rawEntry || typeof rawEntry !== 'object') continue;
|
|
295
|
+
const stream = String(rawEntry.stream || '').trim();
|
|
296
|
+
if (stream) {
|
|
297
|
+
const limit = Number(rawEntry.limit);
|
|
298
|
+
contextAlways.push({
|
|
299
|
+
kind: 'records',
|
|
300
|
+
stream,
|
|
301
|
+
limit: Number.isFinite(limit) && limit > 0 ? Math.min(Math.trunc(limit), 50) : 5,
|
|
302
|
+
});
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
const key = String(rawEntry.kv_key || rawEntry.key || '').trim();
|
|
306
|
+
if (key) contextAlways.push({ kind: 'kv', key });
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Tier 2 — auto-generated read-only tools.
|
|
310
|
+
const contextTools = [];
|
|
311
|
+
for (const rawTool of (Array.isArray(value.context_tools) ? value.context_tools : [])) {
|
|
312
|
+
if (!rawTool || typeof rawTool !== 'object') continue;
|
|
313
|
+
const name = String(rawTool.name || '').trim();
|
|
314
|
+
if (!SAFE_IDENT_RE.test(name)) {
|
|
315
|
+
console.warn(`Skipping context tool with unsafe or missing name: ${JSON.stringify(rawTool.name)}`);
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
const table = String(rawTool.table || '').trim();
|
|
319
|
+
if (!tableNames.has(table)) {
|
|
320
|
+
console.warn(`Skipping context tool ${name}: table "${table}" is not declared in config.state.tables`);
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
const parameters = rawTool.parameters && typeof rawTool.parameters === 'object'
|
|
324
|
+
? rawTool.parameters
|
|
325
|
+
: { type: 'object', properties: {} };
|
|
326
|
+
// Bind order for a positional `where` clause. Defaults to the declared
|
|
327
|
+
// property order so the simple case needs no extra YAML — minus
|
|
328
|
+
// `limit`, which the CLI consumes itself and must never be bound into
|
|
329
|
+
// the WHERE clause.
|
|
330
|
+
const declaredParams = Array.isArray(rawTool.params)
|
|
331
|
+
? rawTool.params.map(p => String(p || '').trim()).filter(p => SAFE_IDENT_RE.test(p))
|
|
332
|
+
: Object.keys(parameters.properties || {}).filter(p => p !== 'limit');
|
|
333
|
+
const limit = Number(rawTool.limit);
|
|
334
|
+
contextTools.push({
|
|
335
|
+
name,
|
|
336
|
+
table,
|
|
337
|
+
description: String(rawTool.description || `List rows from ${table}`),
|
|
338
|
+
parameters,
|
|
339
|
+
// Plugin-authored SQL, same trust model as state.query(): the author
|
|
340
|
+
// owns the clause, the CLI binds the values.
|
|
341
|
+
where: String(rawTool.where || '').trim(),
|
|
342
|
+
params: declaredParams,
|
|
343
|
+
limit: Number.isFinite(limit) && limit > 0 ? Math.min(Math.trunc(limit), 500) : 50,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return { tables, context_always: contextAlways, context_tools: contextTools };
|
|
348
|
+
}
|
|
349
|
+
|
|
159
350
|
/**
|
|
160
351
|
* Parse a plugin manifest from YAML text.
|
|
161
352
|
* @param {string} yamlText - Raw YAML content
|
|
@@ -214,7 +405,7 @@ export function normalizeManifest(raw, source = '') {
|
|
|
214
405
|
}
|
|
215
406
|
|
|
216
407
|
const meta = raw.metadata || raw.meta || {};
|
|
217
|
-
const config = raw.config || raw.plugin || {};
|
|
408
|
+
const config = raw.config || raw.plugin || raw.spec || {};
|
|
218
409
|
const name = meta.name || config.name || '';
|
|
219
410
|
if (!name) {
|
|
220
411
|
if (process.env.DEBUG) {
|
|
@@ -308,6 +499,7 @@ export function normalizeManifest(raw, source = '') {
|
|
|
308
499
|
// config for the local plugin without editing mcp.json.
|
|
309
500
|
const mcpServers = _readMcpServers(config.mcpServers, source);
|
|
310
501
|
const composes = normalizeComposes(config.composes);
|
|
502
|
+
const state = normalizeState(config.state);
|
|
311
503
|
|
|
312
504
|
return {
|
|
313
505
|
apiVersion,
|
|
@@ -327,6 +519,7 @@ export function normalizeManifest(raw, source = '') {
|
|
|
327
519
|
views,
|
|
328
520
|
mcpServers,
|
|
329
521
|
composes,
|
|
522
|
+
state,
|
|
330
523
|
},
|
|
331
524
|
source,
|
|
332
525
|
_dir: source ? path.dirname(source) : '',
|
|
@@ -408,6 +601,19 @@ export function validatePluginManifest(manifest) {
|
|
|
408
601
|
for (const agent of (manifest.config.agents || [])) {
|
|
409
602
|
if (!agent.slug && !agent.name) errors.push('Agent missing slug or name');
|
|
410
603
|
}
|
|
604
|
+
|
|
605
|
+
// State declarations that silently vanished during normalization are
|
|
606
|
+
// exactly the kind of thing an author wants to hear about at validate
|
|
607
|
+
// time rather than discover at runtime.
|
|
608
|
+
const state = manifest.config.state;
|
|
609
|
+
if (state) {
|
|
610
|
+
const declaredTables = new Set((state.tables || []).map(t => t.name));
|
|
611
|
+
for (const tool of (state.context_tools || [])) {
|
|
612
|
+
if (!declaredTables.has(tool.table)) {
|
|
613
|
+
errors.push(`State context tool "${tool.name}" references undeclared table "${tool.table}"`);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
411
617
|
}
|
|
412
618
|
|
|
413
619
|
return {
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
import * as fs from 'node:fs';
|
|
20
20
|
import * as path from 'node:path';
|
|
21
21
|
import { execSync } from 'node:child_process';
|
|
22
|
+
import { load as yamlLoad } from 'js-yaml';
|
|
22
23
|
|
|
23
24
|
export const REQUIREMENTS_FILE = '.bahulam-requirements.json';
|
|
24
25
|
|
|
@@ -168,6 +169,7 @@ function analyzeFile(text, rel, findings) {
|
|
|
168
169
|
if (!name) continue;
|
|
169
170
|
// Ignore Node.js / OS-level env vars users don't set for a plugin.
|
|
170
171
|
if (['NODE_ENV', 'PATH', 'HOME', 'USER', 'PWD', 'SHELL', 'TERM', 'LANG', 'LC_ALL', 'TZ', 'TMPDIR', 'TMP', 'TEMP'].includes(name)) continue;
|
|
172
|
+
if (/_PREFLIGHT$/.test(name)) continue;
|
|
171
173
|
const existing = findings.envVars.get(name) || { name, seen_in: new Set(), credential: false };
|
|
172
174
|
existing.seen_in.add(rel);
|
|
173
175
|
if (CREDENTIAL_SUFFIXES.some(s => name.endsWith(s))) existing.credential = true;
|
|
@@ -278,6 +280,70 @@ function extractSkillsFiles(pluginDir) {
|
|
|
278
280
|
return skills;
|
|
279
281
|
}
|
|
280
282
|
|
|
283
|
+
function readManifestRequirements(pluginDir) {
|
|
284
|
+
for (const name of ['plugin.yaml', 'plugin.yml']) {
|
|
285
|
+
const filePath = path.join(pluginDir, name);
|
|
286
|
+
if (!fs.existsSync(filePath)) continue;
|
|
287
|
+
try {
|
|
288
|
+
const doc = yamlLoad(fs.readFileSync(filePath, 'utf-8')) || {};
|
|
289
|
+
const reqs = doc.config?.requirements;
|
|
290
|
+
return reqs && typeof reqs === 'object' ? reqs : null;
|
|
291
|
+
} catch {
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function normalizeManifestRequirementList(value) {
|
|
299
|
+
if (!value) return [];
|
|
300
|
+
if (Array.isArray(value)) return value;
|
|
301
|
+
if (typeof value === 'string') return [value];
|
|
302
|
+
if (typeof value === 'object') return Object.entries(value).map(([name, detail]) => (
|
|
303
|
+
detail && typeof detail === 'object' ? { name, ...detail } : { name, reason: String(detail || '') }
|
|
304
|
+
));
|
|
305
|
+
return [];
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function mergeManifestRequirements(shape, manifestRequirements) {
|
|
309
|
+
if (!manifestRequirements) return shape;
|
|
310
|
+
|
|
311
|
+
const binaries = new Map((shape.system_binaries || []).map(b => [b.name, b]));
|
|
312
|
+
for (const raw of normalizeManifestRequirementList(manifestRequirements.system)) {
|
|
313
|
+
const name = typeof raw === 'string' ? raw : raw?.name;
|
|
314
|
+
const clean = String(name || '').trim();
|
|
315
|
+
if (!clean) continue;
|
|
316
|
+
const existing = binaries.get(clean) || { name: clean, install_hints: INSTALL_HINTS[clean] || null, seen_in: [] };
|
|
317
|
+
existing.install_hints = raw.install_hints || raw.installHints || existing.install_hints || INSTALL_HINTS[clean] || null;
|
|
318
|
+
existing.version = raw.version || existing.version || null;
|
|
319
|
+
existing.reason = raw.reason || existing.reason || 'Declared by plugin manifest.';
|
|
320
|
+
existing.optional = raw.optional === true || existing.optional === true;
|
|
321
|
+
existing.seen_in = [...new Set([...(existing.seen_in || []), 'plugin.yaml:config.requirements.system'])].slice(0, 5);
|
|
322
|
+
binaries.set(clean, existing);
|
|
323
|
+
}
|
|
324
|
+
shape.system_binaries = [...binaries.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
325
|
+
|
|
326
|
+
const envVars = new Map((shape.env_vars || []).map(v => [v.name, v]));
|
|
327
|
+
for (const raw of normalizeManifestRequirementList(manifestRequirements.env)) {
|
|
328
|
+
const name = typeof raw === 'string' ? raw : raw?.name;
|
|
329
|
+
const clean = String(name || '').trim();
|
|
330
|
+
if (!clean) continue;
|
|
331
|
+
const existing = envVars.get(clean) || { name: clean, seen_in: [], credential: false };
|
|
332
|
+
existing.credential = raw.credential === true || existing.credential || CREDENTIAL_SUFFIXES.some(s => clean.endsWith(s));
|
|
333
|
+
existing.optional = raw.optional === true || existing.optional === true;
|
|
334
|
+
existing.reason = raw.reason || existing.reason || 'Declared by plugin manifest.';
|
|
335
|
+
existing.seen_in = [...new Set([...(existing.seen_in || []), 'plugin.yaml:config.requirements.env'])].slice(0, 5);
|
|
336
|
+
envVars.set(clean, existing);
|
|
337
|
+
}
|
|
338
|
+
shape.env_vars = [...envVars.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
339
|
+
|
|
340
|
+
const files = normalizeManifestRequirementList(manifestRequirements.filesystem);
|
|
341
|
+
if (files.length) {
|
|
342
|
+
shape.filesystem = files.map(item => typeof item === 'string' ? { path: item } : item).filter(Boolean);
|
|
343
|
+
}
|
|
344
|
+
return shape;
|
|
345
|
+
}
|
|
346
|
+
|
|
281
347
|
function extractToolConstraints(discoveredTools) {
|
|
282
348
|
const out = {};
|
|
283
349
|
for (const tool of discoveredTools || []) {
|
|
@@ -377,6 +443,7 @@ export function analyzeRequirements(pluginDir, { discoveredTools = null } = {})
|
|
|
377
443
|
skills_available: skills,
|
|
378
444
|
tool_constraints: toolConstraints,
|
|
379
445
|
};
|
|
446
|
+
mergeManifestRequirements(shape, readManifestRequirements(pluginDir));
|
|
380
447
|
|
|
381
448
|
try {
|
|
382
449
|
fs.writeFileSync(path.join(pluginDir, REQUIREMENTS_FILE), JSON.stringify(shape, null, 2));
|
|
@@ -462,4 +529,3 @@ export function checkRequirementsAgainstHost(reqs) {
|
|
|
462
529
|
|
|
463
530
|
return results;
|
|
464
531
|
}
|
|
465
|
-
|
|
@@ -81,6 +81,91 @@ function truncate(s, n) {
|
|
|
81
81
|
return str.slice(0, n - 1) + '…';
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
function renderStandardRequirements(requirements) {
|
|
85
|
+
const lines = [' requirements:'];
|
|
86
|
+
const bins = requirements?.system_binaries || [];
|
|
87
|
+
lines.push(' system:');
|
|
88
|
+
if (bins.length) {
|
|
89
|
+
for (const b of bins) {
|
|
90
|
+
lines.push(` - name: ${yamlString(b.name)}`);
|
|
91
|
+
lines.push(` reason: Required by composed pi tool runtime.`);
|
|
92
|
+
if (b.install_hints) {
|
|
93
|
+
lines.push(' install_hints:');
|
|
94
|
+
if (b.install_hints.darwin) lines.push(` darwin: ${yamlString(b.install_hints.darwin)}`);
|
|
95
|
+
if (b.install_hints.linux) lines.push(` linux: ${yamlString(b.install_hints.linux)}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
} else {
|
|
99
|
+
lines.push(' []');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const envVars = requirements?.env_vars || [];
|
|
103
|
+
lines.push(' env:');
|
|
104
|
+
if (envVars.length) {
|
|
105
|
+
for (const v of envVars) {
|
|
106
|
+
lines.push(` - name: ${yamlString(v.name)}`);
|
|
107
|
+
lines.push(` credential: ${v.credential ? 'true' : 'false'}`);
|
|
108
|
+
lines.push(` optional: true`);
|
|
109
|
+
lines.push(` reason: Read by composed pi package when that feature is used.`);
|
|
110
|
+
}
|
|
111
|
+
} else {
|
|
112
|
+
lines.push(' []');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
lines.push(' filesystem:');
|
|
116
|
+
lines.push(' - path: .bahulam/plugin-state');
|
|
117
|
+
lines.push(' access: read_write');
|
|
118
|
+
lines.push(' reason: Stores durable notebook records and reports for this generated pack.');
|
|
119
|
+
return lines;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function renderStandardState(slug) {
|
|
123
|
+
return [
|
|
124
|
+
' state:',
|
|
125
|
+
' tables:',
|
|
126
|
+
' - name: items',
|
|
127
|
+
' columns:',
|
|
128
|
+
' - { name: id, type: INTEGER, primary: true, autoincrement: true }',
|
|
129
|
+
' - { name: title, type: TEXT, not_null: true }',
|
|
130
|
+
' - { name: source, type: TEXT, not_null: true, default: "\'\'" }',
|
|
131
|
+
' - { name: notes, type: TEXT, not_null: true, default: "\'\'" }',
|
|
132
|
+
' - { name: topic, type: TEXT, not_null: true, default: "\'\'" }',
|
|
133
|
+
' - { name: created_at, type: TEXT, not_null: true }',
|
|
134
|
+
' - { name: updated_at, type: TEXT, not_null: true }',
|
|
135
|
+
' indexes:',
|
|
136
|
+
' - { columns: [topic] }',
|
|
137
|
+
'',
|
|
138
|
+
' - name: reports',
|
|
139
|
+
' columns:',
|
|
140
|
+
' - { name: id, type: INTEGER, primary: true, autoincrement: true }',
|
|
141
|
+
' - { name: title, type: TEXT, not_null: true }',
|
|
142
|
+
' - { name: summary, type: TEXT, not_null: true }',
|
|
143
|
+
' - { name: item_count, type: INTEGER, not_null: true, default: "0" }',
|
|
144
|
+
' - { name: created_at, type: TEXT, not_null: true }',
|
|
145
|
+
'',
|
|
146
|
+
' context_always:',
|
|
147
|
+
` - { stream: ${slug}_activity, limit: 5 }`,
|
|
148
|
+
'',
|
|
149
|
+
' context_tools:',
|
|
150
|
+
' - name: list_saved_items',
|
|
151
|
+
' table: items',
|
|
152
|
+
' description: List durable notebook items saved by this plugin.',
|
|
153
|
+
' parameters:',
|
|
154
|
+
' type: object',
|
|
155
|
+
' properties:',
|
|
156
|
+
' limit: { type: integer, description: "Max rows to return." }',
|
|
157
|
+
'',
|
|
158
|
+
' - name: list_saved_reports',
|
|
159
|
+
' table: reports',
|
|
160
|
+
' description: List generated outcome reports.',
|
|
161
|
+
' parameters:',
|
|
162
|
+
' type: object',
|
|
163
|
+
' properties:',
|
|
164
|
+
' limit: { type: integer, description: "Max rows to return." }',
|
|
165
|
+
'',
|
|
166
|
+
];
|
|
167
|
+
}
|
|
168
|
+
|
|
84
169
|
/**
|
|
85
170
|
* Compose the "Requirements & constraints" block from the analyzer's
|
|
86
171
|
* findings. Injected into the generated agent's system prompt so the
|
|
@@ -185,7 +270,7 @@ function generatePrompt(packageName, namespace, toolNames, hasState, requirement
|
|
|
185
270
|
* (quoted keys, over-escaping); a small emitter here yields a diff-
|
|
186
271
|
* friendly manifest the user can edit.
|
|
187
272
|
*/
|
|
188
|
-
function renderManifest({ slug, packageName, versionRange, namespace, exposeTools, hasState, hasWorkspace }) {
|
|
273
|
+
function renderManifest({ slug, packageName, versionRange, namespace, exposeTools, hasState, hasWorkspace, requirements }) {
|
|
189
274
|
const versionSpec = versionRange ? `${packageName}@${versionRange}` : packageName;
|
|
190
275
|
const tools = hasState ? [
|
|
191
276
|
' tools:',
|
|
@@ -223,6 +308,17 @@ function renderManifest({ slug, packageName, versionRange, namespace, exposeTool
|
|
|
223
308
|
' id: { type: integer, description: "Item id (from list_items)" }',
|
|
224
309
|
' required: [id]',
|
|
225
310
|
'',
|
|
311
|
+
' - name: build_report',
|
|
312
|
+
' description: Build a durable outcome report from saved notebook items.',
|
|
313
|
+
` tool: ./tools/build-report.mjs`,
|
|
314
|
+
' parameters:',
|
|
315
|
+
' type: object',
|
|
316
|
+
' properties:',
|
|
317
|
+
' title: { type: string, description: "Report title." }',
|
|
318
|
+
' topic: { type: string, description: "Optional topic filter." }',
|
|
319
|
+
' summary: { type: string, description: "Optional agent-authored summary." }',
|
|
320
|
+
' required: [title]',
|
|
321
|
+
'',
|
|
226
322
|
] : [' tools: []', ''];
|
|
227
323
|
|
|
228
324
|
const composesBlock = [
|
|
@@ -254,17 +350,22 @@ function renderManifest({ slug, packageName, versionRange, namespace, exposeTool
|
|
|
254
350
|
` Edit tools/, workspace/, and this manifest to customize.`,
|
|
255
351
|
'',
|
|
256
352
|
'config:',
|
|
353
|
+
...renderStandardRequirements(requirements),
|
|
354
|
+
'',
|
|
355
|
+
...(hasState ? renderStandardState(slug) : []),
|
|
257
356
|
...tools,
|
|
258
357
|
...composesBlock,
|
|
259
358
|
' workspace: ./config/workspace.yaml',
|
|
359
|
+
' agents_from: ./config/agents/',
|
|
260
360
|
'',
|
|
261
361
|
...workspaceBlock,
|
|
262
362
|
].join('\n');
|
|
263
363
|
}
|
|
264
364
|
|
|
265
|
-
function renderAgentFile({ namespace, exposeTools, agentSlug, agentDescription, hasState, systemPrompt }) {
|
|
365
|
+
function renderAgentFile({ namespace, exposeTools, agentSlug, agentAliases = [], agentDescription, hasState, systemPrompt }) {
|
|
266
366
|
const agentToolRefs = [
|
|
267
367
|
...(hasState ? ['save_item', 'list_items', 'drop_item'] : []),
|
|
368
|
+
...(hasState ? ['build_report'] : []),
|
|
268
369
|
...exposeTools.map(t => `${namespace}${COMPOSED_TOOL_SEPARATOR}${t}`),
|
|
269
370
|
];
|
|
270
371
|
|
|
@@ -276,6 +377,7 @@ function renderAgentFile({ namespace, exposeTools, agentSlug, agentDescription,
|
|
|
276
377
|
'metadata:',
|
|
277
378
|
` slug: ${agentSlug}`,
|
|
278
379
|
` name: ${yamlString(agentSlug.replace(/-/g, ' '))}`,
|
|
380
|
+
...(agentAliases.length ? [' aliases:', ...agentAliases.map(alias => ` - ${yamlString(alias)}`)] : []),
|
|
279
381
|
' role: specialist',
|
|
280
382
|
' description: >',
|
|
281
383
|
` ${agentDescription}`,
|
|
@@ -288,6 +390,29 @@ function renderAgentFile({ namespace, exposeTools, agentSlug, agentDescription,
|
|
|
288
390
|
].join('\n');
|
|
289
391
|
}
|
|
290
392
|
|
|
393
|
+
function renderReviewerAgent({ slug, hasState }) {
|
|
394
|
+
const tools = hasState ? ['list_items', 'build_report'] : [];
|
|
395
|
+
return [
|
|
396
|
+
'apiVersion: agent.framework/v1',
|
|
397
|
+
'kind: SubAgent',
|
|
398
|
+
'metadata:',
|
|
399
|
+
' slug: outcome-reviewer',
|
|
400
|
+
' name: Outcome Reviewer',
|
|
401
|
+
' role: reviewer',
|
|
402
|
+
' description: >',
|
|
403
|
+
` Reviews ${slug} outputs, checks whether saved evidence is enough, and prepares a concise handoff report.`,
|
|
404
|
+
'agent:',
|
|
405
|
+
' can_be_delegated_to: true',
|
|
406
|
+
' system_prompt: |',
|
|
407
|
+
` You are the Outcome Reviewer for ${slug}.`,
|
|
408
|
+
' Inspect saved items and tool outputs, identify missing evidence, and produce a compact handoff.',
|
|
409
|
+
' If state tools are available, call list_items first and build_report when the outcome is ready.',
|
|
410
|
+
'tools:',
|
|
411
|
+
...tools.map(t => ` - ${t}`),
|
|
412
|
+
'',
|
|
413
|
+
].join('\n');
|
|
414
|
+
}
|
|
415
|
+
|
|
291
416
|
const SAVE_ITEM_TOOL = `/**
|
|
292
417
|
* save_item — persist a single item to the pack's notebook.
|
|
293
418
|
* Append-style records so a topic can accumulate many entries over time.
|
|
@@ -307,6 +432,17 @@ export async function call(args = {}, options = {}) {
|
|
|
307
432
|
topic: String(args.topic || '').trim(),
|
|
308
433
|
at: new Date().toISOString(),
|
|
309
434
|
};
|
|
435
|
+
if (typeof state.query === 'function') {
|
|
436
|
+
const info = state.query('INSERT INTO items (title, source, notes, topic, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)', [
|
|
437
|
+
item.title, item.source, item.notes, item.topic, item.at, item.at,
|
|
438
|
+
]);
|
|
439
|
+
if (typeof state.append === 'function') state.append('items', item);
|
|
440
|
+
return {
|
|
441
|
+
success: true,
|
|
442
|
+
output: \`Saved item #\${info?.lastInsertRowid || ''}: \${title}\`,
|
|
443
|
+
item: { ...item, id: info?.lastInsertRowid },
|
|
444
|
+
};
|
|
445
|
+
}
|
|
310
446
|
const record = state.append('items', item);
|
|
311
447
|
return {
|
|
312
448
|
success: true,
|
|
@@ -325,6 +461,17 @@ export async function call(args = {}, options = {}) {
|
|
|
325
461
|
|
|
326
462
|
const topic = String(args.topic || '').trim().toLowerCase();
|
|
327
463
|
const limit = Math.max(1, Math.min(500, Number(args.limit) || 50));
|
|
464
|
+
if (typeof state.query === 'function') {
|
|
465
|
+
const rows = topic
|
|
466
|
+
? state.query('SELECT * FROM items WHERE lower(topic) = ? ORDER BY id DESC LIMIT ?', [topic, limit])
|
|
467
|
+
: state.query('SELECT * FROM items ORDER BY id DESC LIMIT ?', [limit]);
|
|
468
|
+
const summary = rows.map(r => \`#\${r.id} · \${r.title || ''}\${r.topic ? ' (' + r.topic + ')' : ''}\`).join('\\n');
|
|
469
|
+
return {
|
|
470
|
+
success: true,
|
|
471
|
+
output: rows.length ? summary : (topic ? \`No items for topic '\${topic}'\` : 'No items yet'),
|
|
472
|
+
items: rows,
|
|
473
|
+
};
|
|
474
|
+
}
|
|
328
475
|
const rows = state.list('items', { limit, order: 'desc' }) || [];
|
|
329
476
|
const filtered = topic
|
|
330
477
|
? rows.filter(r => String(r.payload?.topic || '').toLowerCase() === topic)
|
|
@@ -349,7 +496,14 @@ export async function call(args = {}, options = {}) {
|
|
|
349
496
|
const state = options.state ? await options.state : null;
|
|
350
497
|
if (!state) return { success: false, output: 'Shared blackboard unavailable' };
|
|
351
498
|
|
|
352
|
-
|
|
499
|
+
let info = null;
|
|
500
|
+
if (typeof state.query === 'function') {
|
|
501
|
+
info = state.query('DELETE FROM items WHERE id = ?', [id]);
|
|
502
|
+
} else if (state.db?.prepare) {
|
|
503
|
+
info = state.db.prepare('DELETE FROM records WHERE stream = ? AND id = ?').run('items', id);
|
|
504
|
+
} else {
|
|
505
|
+
return { success: false, output: 'delete is unavailable in this state adapter' };
|
|
506
|
+
}
|
|
353
507
|
return {
|
|
354
508
|
success: info.changes > 0,
|
|
355
509
|
output: info.changes > 0 ? \`Dropped item #\${id}\` : \`No item with id \${id}\`,
|
|
@@ -357,6 +511,50 @@ export async function call(args = {}, options = {}) {
|
|
|
357
511
|
}
|
|
358
512
|
`;
|
|
359
513
|
|
|
514
|
+
const BUILD_REPORT_TOOL = `/**
|
|
515
|
+
* build_report — create a concise durable report from saved notebook items.
|
|
516
|
+
*/
|
|
517
|
+
export async function call(args = {}, options = {}) {
|
|
518
|
+
const title = String(args.title || '').trim();
|
|
519
|
+
if (!title) return { success: false, output: 'title is required' };
|
|
520
|
+
const topic = String(args.topic || '').trim().toLowerCase();
|
|
521
|
+
const state = options.state ? await options.state : null;
|
|
522
|
+
if (!state) return { success: false, output: 'Shared blackboard unavailable' };
|
|
523
|
+
|
|
524
|
+
let items = [];
|
|
525
|
+
if (typeof state.query === 'function') {
|
|
526
|
+
items = topic
|
|
527
|
+
? state.query('SELECT * FROM items WHERE lower(topic) = ? ORDER BY id DESC LIMIT 100', [topic])
|
|
528
|
+
: state.query('SELECT * FROM items ORDER BY id DESC LIMIT 100');
|
|
529
|
+
} else {
|
|
530
|
+
const rows = state.list('items', { limit: 100, order: 'desc' }) || [];
|
|
531
|
+
items = rows.map(r => ({ id: r.id, ...(r.payload || {}), created_at: r.created_at }))
|
|
532
|
+
.filter(r => !topic || String(r.topic || '').toLowerCase() === topic);
|
|
533
|
+
}
|
|
534
|
+
const summary = String(args.summary || '').trim() || (
|
|
535
|
+
items.length
|
|
536
|
+
? \`Report from \${items.length} saved item\${items.length === 1 ? '' : 's'}.\`
|
|
537
|
+
: 'No saved items available yet.'
|
|
538
|
+
);
|
|
539
|
+
const createdAt = new Date().toISOString();
|
|
540
|
+
let reportId = null;
|
|
541
|
+
if (typeof state.query === 'function') {
|
|
542
|
+
const info = state.query('INSERT INTO reports (title, summary, item_count, created_at) VALUES (?, ?, ?, ?)', [
|
|
543
|
+
title, summary, items.length, createdAt,
|
|
544
|
+
]);
|
|
545
|
+
reportId = info?.lastInsertRowid || null;
|
|
546
|
+
}
|
|
547
|
+
if (typeof state.append === 'function') {
|
|
548
|
+
const record = state.append('reports', { title, summary, item_count: items.length, topic, created_at: createdAt });
|
|
549
|
+
reportId ||= record?.id || null;
|
|
550
|
+
}
|
|
551
|
+
return {
|
|
552
|
+
success: true,
|
|
553
|
+
output: { id: reportId, title, summary, item_count: items.length, topic: topic || null, items },
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
`;
|
|
557
|
+
|
|
360
558
|
function renderPanel(slug, packageName, composedToolNames) {
|
|
361
559
|
const example = composedToolNames[0] || 'example_tool';
|
|
362
560
|
return `<!doctype html>
|
|
@@ -523,7 +721,8 @@ export function scaffoldPiPack({
|
|
|
523
721
|
}
|
|
524
722
|
fs.mkdirSync(dest, { recursive: true });
|
|
525
723
|
|
|
526
|
-
const agentSlug =
|
|
724
|
+
const agentSlug = slug;
|
|
725
|
+
const agentAliases = [`${namespace}-specialist`].filter(alias => alias !== agentSlug);
|
|
527
726
|
const agentDescription = truncate(
|
|
528
727
|
`Specialist agent for ${packageName}. Composes ${toolNames.length} tool${toolNames.length === 1 ? '' : 's'} exposed as ${namespace}${COMPOSED_TOOL_SEPARATOR}*.`,
|
|
529
728
|
240,
|
|
@@ -547,19 +746,24 @@ export function scaffoldPiPack({
|
|
|
547
746
|
exposeTools: toolNames,
|
|
548
747
|
hasState: state,
|
|
549
748
|
hasWorkspace: workspace,
|
|
749
|
+
requirements,
|
|
550
750
|
});
|
|
551
751
|
fs.writeFileSync(path.join(dest, 'plugin.yaml'), manifest);
|
|
552
752
|
|
|
553
753
|
const configDir = path.join(dest, 'config');
|
|
554
754
|
fs.mkdirSync(configDir, { recursive: true });
|
|
755
|
+
const agentsDir = path.join(configDir, 'agents');
|
|
756
|
+
fs.mkdirSync(agentsDir, { recursive: true });
|
|
555
757
|
fs.writeFileSync(path.join(configDir, 'workspace.yaml'), renderAgentFile({
|
|
556
758
|
namespace,
|
|
557
759
|
exposeTools: toolNames,
|
|
558
760
|
agentSlug,
|
|
761
|
+
agentAliases,
|
|
559
762
|
agentDescription,
|
|
560
763
|
hasState: state,
|
|
561
764
|
systemPrompt,
|
|
562
765
|
}));
|
|
766
|
+
fs.writeFileSync(path.join(agentsDir, 'outcome-reviewer.yaml'), renderReviewerAgent({ slug, hasState: state }));
|
|
563
767
|
|
|
564
768
|
if (state) {
|
|
565
769
|
const toolsDir = path.join(dest, 'tools');
|
|
@@ -567,6 +771,7 @@ export function scaffoldPiPack({
|
|
|
567
771
|
fs.writeFileSync(path.join(toolsDir, 'save-item.mjs'), SAVE_ITEM_TOOL);
|
|
568
772
|
fs.writeFileSync(path.join(toolsDir, 'list-items.mjs'), LIST_ITEMS_TOOL);
|
|
569
773
|
fs.writeFileSync(path.join(toolsDir, 'drop-item.mjs'), DROP_ITEM_TOOL);
|
|
774
|
+
fs.writeFileSync(path.join(toolsDir, 'build-report.mjs'), BUILD_REPORT_TOOL);
|
|
570
775
|
}
|
|
571
776
|
|
|
572
777
|
if (workspace) {
|