@bahulam/code 0.1.21 → 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/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/terminal/repl-model-form.mjs +11 -9
- package/src/tools/registry.mjs +7 -1
|
@@ -22,7 +22,8 @@ import * as path from 'node:path';
|
|
|
22
22
|
import { pathToFileURL } from 'node:url';
|
|
23
23
|
import { parsePluginManifestFile, validatePluginManifest } from './manifest.mjs';
|
|
24
24
|
import { composedToolName, validateCompose } from './pi-compose.mjs';
|
|
25
|
-
import {
|
|
25
|
+
import { expandStateContextTools } from './state-tools.mjs';
|
|
26
|
+
import { pluginDirs } from '../core/paths.mjs';
|
|
26
27
|
|
|
27
28
|
const TOOL_NAME_RE = /^[A-Za-z_][A-Za-z0-9_-]{0,63}$/;
|
|
28
29
|
const AGENT_SLUG_RE = /^[a-z][a-z0-9-]{0,63}$/;
|
|
@@ -182,6 +183,25 @@ export async function preflightPlugin(pluginDir, opts = {}) {
|
|
|
182
183
|
}
|
|
183
184
|
}
|
|
184
185
|
|
|
186
|
+
// 4b. Generated state tools. A plugin that declares config.state.context_tools
|
|
187
|
+
// gets read-only tools synthesized at load time (state-tools.mjs), so an
|
|
188
|
+
// agent may legitimately allowlist them. Without this, a plugin referencing
|
|
189
|
+
// its own generated tool fails preflight as "not defined by this plugin".
|
|
190
|
+
const stateToolNames = new Set();
|
|
191
|
+
for (const generated of expandStateContextTools(name, pluginDir, manifest.config?.state)) {
|
|
192
|
+
const gname = generated.name;
|
|
193
|
+
if (!gname) continue;
|
|
194
|
+
if (toolNames.has(gname) || composedToolNames.has(gname)) {
|
|
195
|
+
errors.push(`Generated state tool "${gname}" collides with an existing tool of the same name`);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (RESERVED_TOOL_NAMES.has(gname)) {
|
|
199
|
+
errors.push(`Generated state tool "${gname}" shadows a built-in tool — rename it in config.state.context_tools`);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
stateToolNames.add(gname);
|
|
203
|
+
}
|
|
204
|
+
|
|
185
205
|
// 5. Sub-agent checks
|
|
186
206
|
const agentSlugs = new Set();
|
|
187
207
|
for (const [i, agent] of agents.entries()) {
|
|
@@ -207,7 +227,8 @@ export async function preflightPlugin(pluginDir, opts = {}) {
|
|
|
207
227
|
}
|
|
208
228
|
continue;
|
|
209
229
|
}
|
|
210
|
-
if (!toolNames.has(toolRef) && !
|
|
230
|
+
if (!toolNames.has(toolRef) && !stateToolNames.has(toolRef)
|
|
231
|
+
&& !composedToolNames.has(toolRef) && !RESERVED_TOOL_NAMES.has(toolRef)) {
|
|
211
232
|
errors.push(`Agent "${slug}": tool "${toolRef}" is not defined by this plugin and is not a built-in`);
|
|
212
233
|
}
|
|
213
234
|
}
|
|
@@ -245,12 +266,12 @@ export async function preflightPlugin(pluginDir, opts = {}) {
|
|
|
245
266
|
* Convenience — collect installed plugin names from both search paths.
|
|
246
267
|
* Used by the installer to detect collisions.
|
|
247
268
|
*/
|
|
248
|
-
export function existingInstalledNames(
|
|
269
|
+
export function existingInstalledNames(_cwd = process.cwd()) {
|
|
249
270
|
const names = [];
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
271
|
+
// Plugins live in one global root. `cwd` is kept only so existing call
|
|
272
|
+
// sites keep working; it must never influence discovery, since that
|
|
273
|
+
// cwd-dependence is the thing this removed.
|
|
274
|
+
for (const dir of pluginDirs()) {
|
|
254
275
|
if (!fs.existsSync(dir)) continue;
|
|
255
276
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
256
277
|
if (!entry.isDirectory() || entry.name.endsWith('.disabled')) continue;
|
package/src/plugins/registry.mjs
CHANGED
|
@@ -9,24 +9,20 @@ import fs from 'fs';
|
|
|
9
9
|
import path from 'path';
|
|
10
10
|
import { parsePluginManifestFile, validatePluginManifest } from './manifest.mjs';
|
|
11
11
|
import { expandComposedTools } from './pi-compose.mjs';
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
const DEFAULT_PLUGIN_DIRS = () => [
|
|
15
|
-
path.join(process.cwd(), '.bahulam', 'plugins'),
|
|
16
|
-
path.join(bahulamHome(), 'plugins'),
|
|
17
|
-
];
|
|
12
|
+
import { expandStateContextTools } from './state-tools.mjs';
|
|
13
|
+
import { pluginDirs as defaultPluginDirs } from '../core/paths.mjs';
|
|
18
14
|
|
|
19
15
|
export class PluginRegistry {
|
|
20
16
|
/**
|
|
21
17
|
* @param {Object} [options]
|
|
22
|
-
* @param {string[]} [options.pluginDirs] - Directories to scan (default:
|
|
18
|
+
* @param {string[]} [options.pluginDirs] - Directories to scan (default: ~/.bahulam/plugins)
|
|
23
19
|
* @param {string[]} [options.disabled] - Plugin names to skip
|
|
24
20
|
* @param {string[]} [options.enabled] - If provided, only these plugin names are loaded
|
|
25
21
|
* @param {string[]} [options.active] - Alias for enabled
|
|
26
22
|
* @param {string} [options.pluginDir] - Legacy single plugin dir (mapped to pluginDirs[0])
|
|
27
23
|
*/
|
|
28
24
|
constructor({ pluginDirs, disabled = [], enabled = null, active = null, pluginDir } = {}) {
|
|
29
|
-
this.pluginDirs = pluginDirs || (pluginDir ? [pluginDir] :
|
|
25
|
+
this.pluginDirs = pluginDirs || (pluginDir ? [pluginDir] : defaultPluginDirs());
|
|
30
26
|
this.disabled = new Set(
|
|
31
27
|
(Array.isArray(disabled) ? disabled : [])
|
|
32
28
|
.map(s => String(s).trim().toLowerCase())
|
|
@@ -113,7 +109,7 @@ export class PluginRegistry {
|
|
|
113
109
|
return false;
|
|
114
110
|
}
|
|
115
111
|
|
|
116
|
-
//
|
|
112
|
+
// Duplicates are skipped — the first manifest scanned wins.
|
|
117
113
|
if (this.plugins.has(lowerName)) {
|
|
118
114
|
return false; // silently skip duplicates
|
|
119
115
|
}
|
|
@@ -165,6 +161,13 @@ export class PluginRegistry {
|
|
|
165
161
|
plugin._dir,
|
|
166
162
|
plugin.config?.composes || [],
|
|
167
163
|
));
|
|
164
|
+
// Manifest-declared state query tools. Synthesized rather than
|
|
165
|
+
// imported, so they carry `_state_tool` instead of a module path.
|
|
166
|
+
tools.push(...expandStateContextTools(
|
|
167
|
+
plugin.metadata?.name || '',
|
|
168
|
+
plugin._dir,
|
|
169
|
+
plugin.config?.state,
|
|
170
|
+
));
|
|
168
171
|
}
|
|
169
172
|
return tools;
|
|
170
173
|
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Declared-state tool expansion.
|
|
3
|
+
*
|
|
4
|
+
* A plugin that declares tables in `config.state.tables` can ask the CLI
|
|
5
|
+
* to generate read-only query tools for the agent, instead of hand-writing
|
|
6
|
+
* a `tools/*.mjs` handler for every "let me look at my data" case. The
|
|
7
|
+
* manifest block looks like:
|
|
8
|
+
*
|
|
9
|
+
* state:
|
|
10
|
+
* tables:
|
|
11
|
+
* - name: questions
|
|
12
|
+
* columns: [...]
|
|
13
|
+
* context_tools:
|
|
14
|
+
* - name: list_questions
|
|
15
|
+
* table: questions
|
|
16
|
+
* description: List exam questions, optionally filtered by topic
|
|
17
|
+
* parameters:
|
|
18
|
+
* type: object
|
|
19
|
+
* properties:
|
|
20
|
+
* topic: { type: string }
|
|
21
|
+
* where: "topic = ?"
|
|
22
|
+
* params: [topic]
|
|
23
|
+
*
|
|
24
|
+
* `expandStateContextTools` turns that into registry tool entries. The
|
|
25
|
+
* execution side (tool-executor) recognizes the `_state_tool` marker and
|
|
26
|
+
* runs the query against the plugin's own state DB — so the same tool
|
|
27
|
+
* flows through every existing surface: `listPluginToolSchemas()`,
|
|
28
|
+
* the client_tools map, sub-agent tool allowlists, and `/tools`.
|
|
29
|
+
*
|
|
30
|
+
* This mirrors `pi-compose.mjs`: one expansion per composition kind, both
|
|
31
|
+
* feeding the same `PluginRegistry.listTools()` contract.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** Tool names are passed to the model, so keep them in the same charset the rest of the CLI uses. */
|
|
35
|
+
function sanitizeToolName(value) {
|
|
36
|
+
return String(value || '').trim().replace(/[^A-Za-z0-9_]/g, '_');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Expand a plugin's declared `config.state.context_tools` into tool entries.
|
|
41
|
+
*
|
|
42
|
+
* Entries carry no `tool:` module path — they are synthesized, and
|
|
43
|
+
* `_state_tool` tells the executor to query the plugin's state DB rather
|
|
44
|
+
* than import a handler file.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} pluginName
|
|
47
|
+
* @param {string} pluginDir
|
|
48
|
+
* @param {object|null|undefined} state normalized `config.state`
|
|
49
|
+
* @returns {object[]}
|
|
50
|
+
*/
|
|
51
|
+
export function expandStateContextTools(pluginName, pluginDir, state) {
|
|
52
|
+
const tools = [];
|
|
53
|
+
const declared = Array.isArray(state?.context_tools) ? state.context_tools : [];
|
|
54
|
+
if (!pluginName || declared.length === 0) return tools;
|
|
55
|
+
|
|
56
|
+
const tableNames = new Set((state?.tables || []).map(t => t.name));
|
|
57
|
+
|
|
58
|
+
for (const tool of declared) {
|
|
59
|
+
const name = sanitizeToolName(tool?.name);
|
|
60
|
+
if (!name) continue;
|
|
61
|
+
// A generated tool must never reach past the plugin's own declared
|
|
62
|
+
// tables — that is the whole safety story for author-supplied `where`.
|
|
63
|
+
if (!tableNames.has(tool.table)) continue;
|
|
64
|
+
|
|
65
|
+
tools.push({
|
|
66
|
+
name,
|
|
67
|
+
description: tool.description || `List rows from ${tool.table}`,
|
|
68
|
+
input_schema: tool.parameters || { type: 'object', properties: {} },
|
|
69
|
+
// No module to load — signals "synthesized" to any consumer that
|
|
70
|
+
// would otherwise try to resolve a handler file.
|
|
71
|
+
tool: null,
|
|
72
|
+
plugin_name: pluginName,
|
|
73
|
+
_plugin_name: pluginName,
|
|
74
|
+
_plugin_dir: pluginDir,
|
|
75
|
+
_state_tool: {
|
|
76
|
+
plugin: pluginName,
|
|
77
|
+
table: tool.table,
|
|
78
|
+
where: tool.where || '',
|
|
79
|
+
params: Array.isArray(tool.params) ? tool.params : [],
|
|
80
|
+
limit: tool.limit || 50,
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return tools;
|
|
86
|
+
}
|
package/src/plugins/state.mjs
CHANGED
|
@@ -206,17 +206,18 @@ function pluginDataDir(pluginName) {
|
|
|
206
206
|
return dir;
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
-
function openDb(pluginName) {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
209
|
+
function openDb(pluginName, { tables = [] } = {}) {
|
|
210
|
+
let handle = _handles.get(pluginName);
|
|
211
|
+
if (!handle) {
|
|
212
|
+
const dir = pluginDataDir(pluginName);
|
|
213
|
+
const dbPath = path.join(dir, 'state.db');
|
|
214
|
+
const db = DatabaseSync ? new DatabaseSync(dbPath) : new JsonStateDb(dbPath);
|
|
215
|
+
// WAL: multiple readers, one writer; robust against concurrent view+agent.
|
|
216
|
+
db.exec('PRAGMA journal_mode = WAL');
|
|
217
|
+
db.exec('PRAGMA synchronous = NORMAL');
|
|
218
|
+
db.exec('PRAGMA foreign_keys = ON');
|
|
219
|
+
// Bootstrap schema — idempotent so evolving plugins never crash on start.
|
|
220
|
+
db.exec(`
|
|
220
221
|
CREATE TABLE IF NOT EXISTS kv (
|
|
221
222
|
key TEXT PRIMARY KEY,
|
|
222
223
|
value TEXT NOT NULL,
|
|
@@ -231,11 +232,99 @@ function openDb(pluginName) {
|
|
|
231
232
|
CREATE INDEX IF NOT EXISTS records_stream_idx
|
|
232
233
|
ON records(stream, id DESC);
|
|
233
234
|
`);
|
|
234
|
-
|
|
235
|
-
|
|
235
|
+
handle = { db, dir, path: dbPath, schemaSig: null, declaredTables: new Set() };
|
|
236
|
+
_handles.set(pluginName, handle);
|
|
237
|
+
}
|
|
238
|
+
// Declared tables are applied on every open call, not just the first —
|
|
239
|
+
// a plugin installed mid-session (or a version bump that adds columns)
|
|
240
|
+
// must still converge the DB. The signature check makes the repeat
|
|
241
|
+
// calls free once the schema already matches.
|
|
242
|
+
applyDeclaredSchema(handle, tables);
|
|
236
243
|
return handle;
|
|
237
244
|
}
|
|
238
245
|
|
|
246
|
+
function quoteIdent(name) {
|
|
247
|
+
return `"${String(name).replace(/"/g, '""')}"`;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Render one normalized column as the DDL fragment it contributes. */
|
|
251
|
+
function columnToSql(col) {
|
|
252
|
+
const parts = [quoteIdent(col.name), col.type];
|
|
253
|
+
if (col.primary) parts.push('PRIMARY KEY');
|
|
254
|
+
if (col.autoincrement) parts.push('AUTOINCREMENT');
|
|
255
|
+
if (col.not_null) parts.push('NOT NULL');
|
|
256
|
+
// `default` is author-supplied SQL text (e.g. "'medium'", "0"), which is
|
|
257
|
+
// why normalizeState stringifies it and why it is never parameterized.
|
|
258
|
+
if (col.default != null) parts.push(`DEFAULT ${col.default}`);
|
|
259
|
+
if (col.references) parts.push(`REFERENCES ${col.references}`);
|
|
260
|
+
return parts.join(' ');
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Create/evolve the tables a plugin declared in `config.state.tables`.
|
|
265
|
+
*
|
|
266
|
+
* Additive by contract: a table that exists is left alone apart from
|
|
267
|
+
* genuinely new columns, which are appended with ALTER TABLE. Nothing is
|
|
268
|
+
* ever dropped or retyped, so bumping a plugin version can't cost the
|
|
269
|
+
* user their data. Re-running with an unchanged declaration is a no-op.
|
|
270
|
+
*
|
|
271
|
+
* Silently does nothing on the JSON fallback backend, where arbitrary SQL
|
|
272
|
+
* isn't available — plugins that need declared tables need node:sqlite.
|
|
273
|
+
*/
|
|
274
|
+
function applyDeclaredSchema(handle, tables) {
|
|
275
|
+
if (!DatabaseSync) return;
|
|
276
|
+
if (!Array.isArray(tables) || tables.length === 0) {
|
|
277
|
+
handle.declaredTables = new Set();
|
|
278
|
+
handle.schemaSig = null;
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const sig = JSON.stringify(tables);
|
|
282
|
+
if (handle.schemaSig === sig) return;
|
|
283
|
+
|
|
284
|
+
const { db } = handle;
|
|
285
|
+
for (const table of tables) {
|
|
286
|
+
let existing = new Set();
|
|
287
|
+
try {
|
|
288
|
+
existing = new Set(db.prepare(`PRAGMA table_info(${quoteIdent(table.name)})`).all().map(r => String(r.name)));
|
|
289
|
+
} catch { /* table doesn't exist yet — created below */ }
|
|
290
|
+
|
|
291
|
+
try {
|
|
292
|
+
if (existing.size === 0) {
|
|
293
|
+
const defs = table.columns.map(columnToSql).join(', ');
|
|
294
|
+
db.exec(`CREATE TABLE IF NOT EXISTS ${quoteIdent(table.name)} (${defs});`);
|
|
295
|
+
} else {
|
|
296
|
+
for (const col of table.columns) {
|
|
297
|
+
if (existing.has(col.name)) continue;
|
|
298
|
+
// SQLite refuses PRIMARY KEY / NOT NULL-without-default on ALTER
|
|
299
|
+
// TABLE ADD COLUMN. Strip those rather than fail the migration.
|
|
300
|
+
const addable = {
|
|
301
|
+
...col,
|
|
302
|
+
primary: false,
|
|
303
|
+
autoincrement: false,
|
|
304
|
+
not_null: col.not_null && col.default != null,
|
|
305
|
+
};
|
|
306
|
+
db.exec(`ALTER TABLE ${quoteIdent(table.name)} ADD COLUMN ${columnToSql(addable)};`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
for (const index of (table.indexes || [])) {
|
|
310
|
+
const cols = index.columns.map(quoteIdent).join(', ');
|
|
311
|
+
const idxName = `${table.name}_${index.columns.join('_')}_idx`;
|
|
312
|
+
db.exec(
|
|
313
|
+
`CREATE ${index.unique ? 'UNIQUE ' : ''}INDEX IF NOT EXISTS ${quoteIdent(idxName)} `
|
|
314
|
+
+ `ON ${quoteIdent(table.name)} (${cols});`,
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
} catch (err) {
|
|
318
|
+
if (process.env.DEBUG) {
|
|
319
|
+
console.error(`Failed to apply declared schema for table ${table.name}: ${err.message}`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
handle.declaredTables = new Set(tables.map(t => t.name));
|
|
325
|
+
handle.schemaSig = sig;
|
|
326
|
+
}
|
|
327
|
+
|
|
239
328
|
function now() { return new Date().toISOString(); }
|
|
240
329
|
|
|
241
330
|
function deepMerge(base, patch) {
|
|
@@ -261,10 +350,15 @@ function deepMerge(base, patch) {
|
|
|
261
350
|
* @param {(evt: {plugin: string, op: string, kind: 'kv'|'records', target: string, at: string}) => void} [opts.emit]
|
|
262
351
|
* Called (debounced) after every write commits. The workspace server
|
|
263
352
|
* turns this into an SSE `plugin_state_changed` event for the browser.
|
|
264
|
-
* @
|
|
353
|
+
* @param {object[]} [opts.tables]
|
|
354
|
+
* Tables declared in the plugin manifest's `config.state.tables`. They
|
|
355
|
+
* are created (and additively migrated) when the DB opens, so a plugin
|
|
356
|
+
* can own real domain tables — questions, documents, answers — without
|
|
357
|
+
* shipping its own migration logic.
|
|
358
|
+
* @returns proxy with { get, set, patch, append, list, query, delete, summary, readTable, close, db, path }
|
|
265
359
|
*/
|
|
266
|
-
export function makePluginState(pluginName, { emit = null } = {}) {
|
|
267
|
-
const { db, path: dbPath } = openDb(pluginName);
|
|
360
|
+
export function makePluginState(pluginName, { emit = null, tables = [] } = {}) {
|
|
361
|
+
const { db, path: dbPath, declaredTables } = openDb(pluginName, { tables });
|
|
268
362
|
|
|
269
363
|
// One debounce timer per (kind, target). Fast writes coalesce into
|
|
270
364
|
// exactly one plugin_state_changed event. Pending entry is stored so
|
|
@@ -398,6 +492,64 @@ export function makePluginState(pluginName, { emit = null } = {}) {
|
|
|
398
492
|
return { changes: info.changes, lastInsertRowid: Number(info.lastInsertRowid) };
|
|
399
493
|
},
|
|
400
494
|
|
|
495
|
+
/**
|
|
496
|
+
* Read the slices a manifest flagged as `context_always` so the agent
|
|
497
|
+
* can orient itself at session start without spending a tool call.
|
|
498
|
+
*
|
|
499
|
+
* Returns `{ kv: { <key>: value }, streams: { <stream>: [rows] } }`.
|
|
500
|
+
* Anything the declaration doesn't name is deliberately omitted —
|
|
501
|
+
* this is the "small and high-signal" tier, and its whole value is
|
|
502
|
+
* that it stays cheap.
|
|
503
|
+
*
|
|
504
|
+
* @param {object[]} decl normalized `config.state.context_always`
|
|
505
|
+
*/
|
|
506
|
+
summary(decl = []) {
|
|
507
|
+
const out = { kv: {}, streams: {} };
|
|
508
|
+
for (const item of (Array.isArray(decl) ? decl : [])) {
|
|
509
|
+
if (!item || typeof item !== 'object') continue;
|
|
510
|
+
try {
|
|
511
|
+
if (item.kind === 'records' && item.stream) {
|
|
512
|
+
out.streams[item.stream] = this.list(item.stream, {
|
|
513
|
+
limit: item.limit || 5,
|
|
514
|
+
order: 'desc',
|
|
515
|
+
});
|
|
516
|
+
} else if (item.kind === 'kv' && item.key) {
|
|
517
|
+
out.kv[item.key] = this.get(item.key, null);
|
|
518
|
+
}
|
|
519
|
+
} catch { /* one unreadable slice must not blank the whole summary */ }
|
|
520
|
+
}
|
|
521
|
+
return out;
|
|
522
|
+
},
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Read rows from a table the plugin declared in `config.state.tables`.
|
|
526
|
+
* Backs the auto-generated `context_tools`.
|
|
527
|
+
*
|
|
528
|
+
* Only declared tables are reachable, so a manifest typo can't turn a
|
|
529
|
+
* generated tool into arbitrary table access. `where` is plugin-authored
|
|
530
|
+
* SQL (same trust model as `query()`); values are always bound.
|
|
531
|
+
*
|
|
532
|
+
* @param {string} table
|
|
533
|
+
* @param {object} [opts]
|
|
534
|
+
* @param {string} [opts.where] e.g. "topic = ?"
|
|
535
|
+
* @param {any[]} [opts.params] bound positionally against `where`
|
|
536
|
+
* @param {number} [opts.limit]
|
|
537
|
+
* @returns {object[]}
|
|
538
|
+
*/
|
|
539
|
+
readTable(table, { where = '', params = [], limit = 50 } = {}) {
|
|
540
|
+
if (!DatabaseSync) {
|
|
541
|
+
throw new Error('Declared state tables require the native node:sqlite backend');
|
|
542
|
+
}
|
|
543
|
+
const name = String(table || '');
|
|
544
|
+
if (!declaredTables.has(name)) {
|
|
545
|
+
throw new Error(`unknown declared state table: ${name || '(none)'}`);
|
|
546
|
+
}
|
|
547
|
+
const args = Array.isArray(params) ? params : [params];
|
|
548
|
+
const cap = Math.max(1, Math.min(1000, Math.floor(limit) || 50));
|
|
549
|
+
const clause = where ? ` WHERE ${where}` : '';
|
|
550
|
+
return db.prepare(`SELECT * FROM ${quoteIdent(name)}${clause} LIMIT ?`).all(...args, cap);
|
|
551
|
+
},
|
|
552
|
+
|
|
401
553
|
/** Direct DatabaseSync handle for callers that know what they need. */
|
|
402
554
|
get db() { return db; },
|
|
403
555
|
/** Absolute path to the DB file on disk. */
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Interactive per-role model form for /model (PRD-076 W7).
|
|
3
3
|
*
|
|
4
|
-
* ↑↓ picks a role row, ←→ cycles through [backend default] + the
|
|
4
|
+
* ↑↓ picks a role row, ←→ cycles through [backend default] + the curated
|
|
5
5
|
* platform catalog for that role, Enter applies to session overrides,
|
|
6
6
|
* c resets every row to default, Esc cancels. Same raw-stdin overlay
|
|
7
7
|
* pattern as the resume picker (repl-resume.mjs): pause readline, raw
|
|
@@ -35,19 +35,19 @@ function formatTokenLimit(value, label) {
|
|
|
35
35
|
return `${Math.round(n)} ${label}`;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
const
|
|
38
|
+
function optionRowsForRole(catalog, row) {
|
|
39
|
+
const curated = (catalog || []).filter(m => m?.harness_validated && m?.id);
|
|
40
40
|
const group = String(row?.optionGroup || 'text').toLowerCase();
|
|
41
41
|
if (group === 'image_analysis') {
|
|
42
|
-
return
|
|
42
|
+
return curated.filter(m => (
|
|
43
43
|
['image', 'multimodal'].includes(modelCategory(m))
|
|
44
44
|
&& !isImageGenerationModel(m)
|
|
45
45
|
));
|
|
46
46
|
}
|
|
47
47
|
if (group === 'image_generation') {
|
|
48
|
-
return
|
|
48
|
+
return curated.filter(m => modelCategory(m) === 'image' && isImageGenerationModel(m));
|
|
49
49
|
}
|
|
50
|
-
return
|
|
50
|
+
return curated.filter(m => ['text', 'chat'].includes(modelCategory(m)));
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
/**
|
|
@@ -63,11 +63,11 @@ export async function pickModelOverridesForm({ rl, roles, catalog, fallbackIds,
|
|
|
63
63
|
if (!process.stdin.isTTY) return null;
|
|
64
64
|
if (rl) rl.pause();
|
|
65
65
|
|
|
66
|
-
const catalogRows = (catalog || []).filter(m => m?.id);
|
|
66
|
+
const catalogRows = (catalog || []).filter(m => m?.harness_validated && m?.id);
|
|
67
67
|
const usingFallback = catalogRows.length === 0;
|
|
68
68
|
const byId = new Map(catalogRows.map(m => [m.id, m]));
|
|
69
69
|
|
|
70
|
-
// Per-row option list; a current override that isn't in the
|
|
70
|
+
// Per-row option list; a current override that isn't in the curated list
|
|
71
71
|
// is appended so it stays visible and selectable.
|
|
72
72
|
const rows = roles.map(r => {
|
|
73
73
|
const optionIds = usingFallback
|
|
@@ -99,7 +99,9 @@ export async function pickModelOverridesForm({ rl, roles, catalog, fallbackIds,
|
|
|
99
99
|
}
|
|
100
100
|
const meta = byId.get(value);
|
|
101
101
|
const badge = meta ? creditBadge(meta) : '';
|
|
102
|
-
|
|
102
|
+
// Only flag uncurated picks when a curated catalog actually loaded —
|
|
103
|
+
// in fallback mode every option is a known backend model, not a stray.
|
|
104
|
+
const flag = meta || usingFallback ? '' : c.yellow(' (uncurated)');
|
|
103
105
|
return `${c.brand(value)}${badge ? ` ${c.dim(badge)}` : ''}${flag}`;
|
|
104
106
|
};
|
|
105
107
|
|
package/src/tools/registry.mjs
CHANGED
|
@@ -111,7 +111,13 @@ export function createToolRegistry({
|
|
|
111
111
|
if (!pluginName) return null;
|
|
112
112
|
if (pluginStateHandles.has(pluginName)) return pluginStateHandles.get(pluginName);
|
|
113
113
|
const { makePluginState } = await import('../plugins/state.mjs');
|
|
114
|
-
|
|
114
|
+
// Pass the plugin's declared tables through, otherwise this path
|
|
115
|
+
// opens the shared handle without them — and `applyDeclaredSchema`
|
|
116
|
+
// treats an empty list as "this plugin declares nothing", which
|
|
117
|
+
// would *un-apply* a schema the executor path had already applied.
|
|
118
|
+
const plugin = pluginRegistry?.get?.(pluginName) || null;
|
|
119
|
+
const tables = plugin?.config?.state?.tables || [];
|
|
120
|
+
const state = makePluginState(pluginName, { emit: stateEmit, tables });
|
|
115
121
|
pluginStateHandles.set(pluginName, state);
|
|
116
122
|
return state;
|
|
117
123
|
}
|