@bahulam/code 0.1.11 → 0.1.13
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/auth/tarang-auth.mjs +313 -0
- package/src/commands/agent.mjs +7 -7
- package/src/commands/install.mjs +295 -0
- package/src/commands/plugin-manage.mjs +280 -88
- package/src/config/cli-args.mjs +16 -0
- package/src/config/settings-loader.mjs +15 -0
- package/src/core/background-tasks.mjs +186 -0
- package/src/core/headless.mjs +54 -3
- package/src/core/local-agent.mjs +10 -1
- package/src/core/risk-tier.mjs +1 -0
- package/src/core/stream-client.mjs +95 -15
- package/src/core/tool-executor.mjs +266 -15
- package/src/local-service/agent-relay.mjs +1 -1
- package/src/local-service/server.mjs +116 -14
- 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 +2 -2
- package/src/plugins/manifest.mjs +30 -27
- package/src/plugins/pi-compat/loader-hook.mjs +45 -0
- package/src/plugins/pi-compat/probe.mjs +294 -0
- package/src/plugins/pi-compat/scaffold.mjs +487 -0
- package/src/plugins/pi-compat/shim.mjs +134 -0
- package/src/plugins/pi-compose.mjs +147 -0
- package/src/plugins/preflight.mjs +35 -10
- package/src/plugins/registry.mjs +6 -0
- package/src/terminal/agents.mjs +8 -3
- package/src/terminal/main.mjs +39 -7
- package/src/terminal/paste-input.mjs +23 -0
- package/src/terminal/repl-render.mjs +65 -10
- package/src/terminal/repl-state.mjs +4 -2
- package/src/terminal/repl.mjs +624 -103
- package/src/tools/agent.mjs +6 -2
- package/src/tools/registry.mjs +107 -4
- package/src/ui/input-dock.mjs +5 -2
- package/src/ui/slash-commands.mjs +1 -1
- package/src/ui/sub-agent.mjs +14 -8
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scaffoldPiPack — generate a full Bahulam pack directory from an
|
|
3
|
+
* installed pi ingredient. The generated pack composes the pi package's
|
|
4
|
+
* tools, adds a persistent state layer (append-only records), a single
|
|
5
|
+
* agent that has both toolsets, and a workspace panel that reads the
|
|
6
|
+
* state live via SSE.
|
|
7
|
+
*
|
|
8
|
+
* The pack is written into ~/.bahulam/plugins/<slug>/ (or the target
|
|
9
|
+
* dir the caller provides) so `bahulam install pi:<pkg>` is one command
|
|
10
|
+
* and the pack is immediately installed + preflight-approved.
|
|
11
|
+
*
|
|
12
|
+
* Design tenets:
|
|
13
|
+
* - Zero authoring cost. All content templated from the pi tools cache.
|
|
14
|
+
* - Composed pi tools ARE the surface — no need to wrap each one.
|
|
15
|
+
* - Native tools are limited to the state layer (save/list/drop item).
|
|
16
|
+
* This bridges the pi surface (stateless) to the workspace panel.
|
|
17
|
+
* - Everything is overridable. The user can edit the generated files
|
|
18
|
+
* after the fact — they live in ~/.bahulam/plugins/<slug>/ and won't
|
|
19
|
+
* be regenerated on re-install.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import * as fs from 'node:fs';
|
|
23
|
+
import * as path from 'node:path';
|
|
24
|
+
import { COMPOSED_TOOL_SEPARATOR } from '../pi-compose.mjs';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Derive a pack slug from a pi package name.
|
|
28
|
+
* pi-web-access → web-access-studio
|
|
29
|
+
* pi-redmine → redmine-studio
|
|
30
|
+
* @ffmpeg/transitions → transitions-studio
|
|
31
|
+
* plain-name → plain-name-studio
|
|
32
|
+
*/
|
|
33
|
+
export function deriveSlug(packageName) {
|
|
34
|
+
let base = String(packageName || '').trim();
|
|
35
|
+
const scoped = base.match(/^@[^/]+\/(.+)$/);
|
|
36
|
+
if (scoped) base = scoped[1];
|
|
37
|
+
base = base.replace(/^pi-/, '');
|
|
38
|
+
base = base.replace(/[^a-z0-9-]+/gi, '-').replace(/^-+|-+$/g, '').toLowerCase();
|
|
39
|
+
if (!base) base = 'pi-pack';
|
|
40
|
+
return `${base}-studio`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Derive a short namespace prefix from the pi package name. Used as
|
|
45
|
+
* `as:` in the composes block. Kept short so composed tool names stay
|
|
46
|
+
* readable: `web__web_search`, `fx__add_transitions` (double underscore
|
|
47
|
+
* matches Claude Code / MCP naming and passes Anthropic's tool-name regex).
|
|
48
|
+
*/
|
|
49
|
+
export function deriveNamespace(packageName) {
|
|
50
|
+
let base = String(packageName || '').trim();
|
|
51
|
+
const scoped = base.match(/^@([^/]+)\/(.+)$/);
|
|
52
|
+
if (scoped) base = scoped[2];
|
|
53
|
+
base = base.replace(/^pi-/, '');
|
|
54
|
+
const first = base.split(/[-_.]/)[0] || 'pi';
|
|
55
|
+
return first.slice(0, 8).toLowerCase();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function yamlString(s) {
|
|
59
|
+
const str = String(s || '');
|
|
60
|
+
if (str === '' || /[:#{}\[\],&*!|>'"%@`\n]/.test(str)) {
|
|
61
|
+
return JSON.stringify(str);
|
|
62
|
+
}
|
|
63
|
+
return str;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function yamlBlock(text, indent) {
|
|
67
|
+
const pad = ' '.repeat(indent);
|
|
68
|
+
const clean = String(text || '').trim().replace(/\r\n/g, '\n');
|
|
69
|
+
if (!clean) return '""';
|
|
70
|
+
const lines = clean.split('\n').map(l => l.trimEnd());
|
|
71
|
+
return '|\n' + lines.map(l => pad + l).join('\n');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function truncate(s, n) {
|
|
75
|
+
const str = String(s || '').replace(/\s+/g, ' ').trim();
|
|
76
|
+
if (str.length <= n) return str;
|
|
77
|
+
return str.slice(0, n - 1) + '…';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Compose an agent system prompt from the pi package + its tools.
|
|
82
|
+
* Focused on WHAT the agent should do, not step-by-step recipes — the
|
|
83
|
+
* generic template can't know the pack's domain. Users are expected to
|
|
84
|
+
* edit the prompt after generation.
|
|
85
|
+
*/
|
|
86
|
+
function generatePrompt(packageName, namespace, toolNames, hasState) {
|
|
87
|
+
const composed = toolNames.map(t => `${namespace}${COMPOSED_TOOL_SEPARATOR}${t}`);
|
|
88
|
+
const stateLines = hasState
|
|
89
|
+
? [
|
|
90
|
+
'',
|
|
91
|
+
'Persistence:',
|
|
92
|
+
'- Call `list_items` first when the user references prior work — the notebook',
|
|
93
|
+
' survives across turns and populates the workspace panel.',
|
|
94
|
+
'- After any meaningful tool call, `save_item` with a title, source, and short note',
|
|
95
|
+
' so the finding is durable and visible in the panel.',
|
|
96
|
+
'- `drop_item` removes an entry by id.',
|
|
97
|
+
]
|
|
98
|
+
: [];
|
|
99
|
+
const lines = [
|
|
100
|
+
`You are the ${packageName} specialist.`,
|
|
101
|
+
`You have direct access to ${toolNames.length} composed tool${toolNames.length === 1 ? '' : 's'} from the pi package \`${packageName}\`.`,
|
|
102
|
+
'',
|
|
103
|
+
'Available composed tools:',
|
|
104
|
+
...composed.map(t => `- \`${t}\``),
|
|
105
|
+
...stateLines,
|
|
106
|
+
'',
|
|
107
|
+
'Rules:',
|
|
108
|
+
'- Use the composed tools directly — do not describe what you would do, DO it.',
|
|
109
|
+
'- If a tool fails, report the exact error message. Do NOT fall back to general',
|
|
110
|
+
' knowledge for tasks the tool was meant to answer.',
|
|
111
|
+
'- Cite sources or IDs from tool responses whenever you make a claim.',
|
|
112
|
+
];
|
|
113
|
+
return lines.join('\n');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Emit plugin.yaml as a hand-crafted string. YAML libraries add noise
|
|
118
|
+
* (quoted keys, over-escaping); a small emitter here yields a diff-
|
|
119
|
+
* friendly manifest the user can edit.
|
|
120
|
+
*/
|
|
121
|
+
function renderManifest({ slug, packageName, versionRange, namespace, exposeTools, agentSlug, agentDescription, hasState, hasWorkspace, systemPrompt }) {
|
|
122
|
+
const versionSpec = versionRange ? `${packageName}@${versionRange}` : packageName;
|
|
123
|
+
const tools = hasState ? [
|
|
124
|
+
' tools:',
|
|
125
|
+
' - name: save_item',
|
|
126
|
+
' description: >',
|
|
127
|
+
` Persist an item to the ${slug} notebook — a title, an optional source URL,`,
|
|
128
|
+
' and freeform notes. The workspace panel and future turns see it immediately.',
|
|
129
|
+
` tool: ./tools/save-item.mjs`,
|
|
130
|
+
' parameters:',
|
|
131
|
+
' type: object',
|
|
132
|
+
' properties:',
|
|
133
|
+
' title: { type: string, description: "Short headline for the item" }',
|
|
134
|
+
' source: { type: string, description: "Source URL or identifier (optional)" }',
|
|
135
|
+
' notes: { type: string, description: "Freeform notes (optional)" }',
|
|
136
|
+
' topic: { type: string, description: "Topic tag for filtering (optional)" }',
|
|
137
|
+
' required: [title]',
|
|
138
|
+
'',
|
|
139
|
+
' - name: list_items',
|
|
140
|
+
' description: >',
|
|
141
|
+
' Read persisted items from the notebook. Use this first when the user',
|
|
142
|
+
' references prior work, before re-running composed tools.',
|
|
143
|
+
` tool: ./tools/list-items.mjs`,
|
|
144
|
+
' parameters:',
|
|
145
|
+
' type: object',
|
|
146
|
+
' properties:',
|
|
147
|
+
' topic: { type: string, description: "Filter by topic tag (exact match). Omit for all." }',
|
|
148
|
+
' limit: { type: integer, description: "Max rows, default 50" }',
|
|
149
|
+
'',
|
|
150
|
+
' - name: drop_item',
|
|
151
|
+
' description: Remove a persisted item by its id.',
|
|
152
|
+
` tool: ./tools/drop-item.mjs`,
|
|
153
|
+
' parameters:',
|
|
154
|
+
' type: object',
|
|
155
|
+
' properties:',
|
|
156
|
+
' id: { type: integer, description: "Item id (from list_items)" }',
|
|
157
|
+
' required: [id]',
|
|
158
|
+
'',
|
|
159
|
+
] : [' tools: []', ''];
|
|
160
|
+
|
|
161
|
+
const composesBlock = [
|
|
162
|
+
' composes:',
|
|
163
|
+
` - source: pi:${versionSpec}`,
|
|
164
|
+
` as: ${namespace}`,
|
|
165
|
+
' expose:',
|
|
166
|
+
...exposeTools.map(t => ` - ${t}`),
|
|
167
|
+
' verified: true',
|
|
168
|
+
'',
|
|
169
|
+
];
|
|
170
|
+
|
|
171
|
+
const agentToolRefs = [
|
|
172
|
+
...(hasState ? ['save_item', 'list_items', 'drop_item'] : []),
|
|
173
|
+
...exposeTools.map(t => `${namespace}${COMPOSED_TOOL_SEPARATOR}${t}`),
|
|
174
|
+
];
|
|
175
|
+
|
|
176
|
+
const agentBlock = [
|
|
177
|
+
' agents:',
|
|
178
|
+
` - slug: ${agentSlug}`,
|
|
179
|
+
` name: ${yamlString(agentSlug.replace(/-/g, ' '))}`,
|
|
180
|
+
' role: specialist',
|
|
181
|
+
' description: >',
|
|
182
|
+
` ${agentDescription}`,
|
|
183
|
+
' tools:',
|
|
184
|
+
...agentToolRefs.map(t => ` - ${t}`),
|
|
185
|
+
` system_prompt: ${yamlBlock(systemPrompt, 8)}`,
|
|
186
|
+
'',
|
|
187
|
+
];
|
|
188
|
+
|
|
189
|
+
const workspaceBlock = hasWorkspace ? [
|
|
190
|
+
' workspace:',
|
|
191
|
+
' views:',
|
|
192
|
+
' - type: panel',
|
|
193
|
+
` name: ${yamlString(slug.replace(/-/g, ' '))}`,
|
|
194
|
+
' source: ./workspace/panel.html',
|
|
195
|
+
'',
|
|
196
|
+
] : [];
|
|
197
|
+
|
|
198
|
+
return [
|
|
199
|
+
'apiVersion: bahulam.plugin/1',
|
|
200
|
+
'kind: Plugin',
|
|
201
|
+
'metadata:',
|
|
202
|
+
` name: ${slug}`,
|
|
203
|
+
' version: 0.1.0',
|
|
204
|
+
' description: >',
|
|
205
|
+
` Auto-scaffolded pack composing pi:${packageName}.`,
|
|
206
|
+
` Edit tools/, workspace/, and this manifest to customize.`,
|
|
207
|
+
'',
|
|
208
|
+
'spec:',
|
|
209
|
+
...tools,
|
|
210
|
+
...composesBlock,
|
|
211
|
+
...agentBlock,
|
|
212
|
+
...workspaceBlock,
|
|
213
|
+
].join('\n');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const SAVE_ITEM_TOOL = `/**
|
|
217
|
+
* save_item — persist a single item to the pack's notebook.
|
|
218
|
+
* Append-style records so a topic can accumulate many entries over time.
|
|
219
|
+
* The workspace panel binds to this stream.
|
|
220
|
+
*/
|
|
221
|
+
export async function call(args = {}, options = {}) {
|
|
222
|
+
const title = String(args.title || '').trim();
|
|
223
|
+
if (!title) return { success: false, output: 'title is required' };
|
|
224
|
+
|
|
225
|
+
const state = options.state ? await options.state : null;
|
|
226
|
+
if (!state) return { success: false, output: 'Shared blackboard unavailable' };
|
|
227
|
+
|
|
228
|
+
const item = {
|
|
229
|
+
title,
|
|
230
|
+
source: String(args.source || '').trim(),
|
|
231
|
+
notes: String(args.notes || '').slice(0, 1000),
|
|
232
|
+
topic: String(args.topic || '').trim(),
|
|
233
|
+
at: new Date().toISOString(),
|
|
234
|
+
};
|
|
235
|
+
const record = state.append('items', item);
|
|
236
|
+
return {
|
|
237
|
+
success: true,
|
|
238
|
+
output: \`Saved item #\${record?.id || ''}: \${title}\`,
|
|
239
|
+
item: { ...item, id: record?.id },
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
`;
|
|
243
|
+
|
|
244
|
+
const LIST_ITEMS_TOOL = `/**
|
|
245
|
+
* list_items — read persisted items from the pack notebook, most recent first.
|
|
246
|
+
*/
|
|
247
|
+
export async function call(args = {}, options = {}) {
|
|
248
|
+
const state = options.state ? await options.state : null;
|
|
249
|
+
if (!state) return { success: false, output: 'Shared blackboard unavailable' };
|
|
250
|
+
|
|
251
|
+
const topic = String(args.topic || '').trim().toLowerCase();
|
|
252
|
+
const limit = Math.max(1, Math.min(500, Number(args.limit) || 50));
|
|
253
|
+
const rows = state.list('items', { limit, order: 'desc' }) || [];
|
|
254
|
+
const filtered = topic
|
|
255
|
+
? rows.filter(r => String(r.payload?.topic || '').toLowerCase() === topic)
|
|
256
|
+
: rows;
|
|
257
|
+
const summary = filtered.map(r => \`#\${r.id} · \${r.payload?.title || ''}\${r.payload?.topic ? ' (' + r.payload.topic + ')' : ''}\`).join('\\n');
|
|
258
|
+
return {
|
|
259
|
+
success: true,
|
|
260
|
+
output: filtered.length ? summary : (topic ? \`No items for topic '\${topic}'\` : 'No items yet'),
|
|
261
|
+
items: filtered.map(r => ({ id: r.id, ...(r.payload || {}), created_at: r.created_at })),
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
`;
|
|
265
|
+
|
|
266
|
+
const DROP_ITEM_TOOL = `/**
|
|
267
|
+
* drop_item — remove a persisted item by id.
|
|
268
|
+
*/
|
|
269
|
+
export async function call(args = {}, options = {}) {
|
|
270
|
+
const id = Number(args.id);
|
|
271
|
+
if (!Number.isInteger(id) || id <= 0) {
|
|
272
|
+
return { success: false, output: 'id must be a positive integer' };
|
|
273
|
+
}
|
|
274
|
+
const state = options.state ? await options.state : null;
|
|
275
|
+
if (!state) return { success: false, output: 'Shared blackboard unavailable' };
|
|
276
|
+
|
|
277
|
+
const info = state.db.prepare('DELETE FROM records WHERE stream = ? AND id = ?').run('items', id);
|
|
278
|
+
return {
|
|
279
|
+
success: info.changes > 0,
|
|
280
|
+
output: info.changes > 0 ? \`Dropped item #\${id}\` : \`No item with id \${id}\`,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
`;
|
|
284
|
+
|
|
285
|
+
function renderPanel(slug, packageName, composedToolNames) {
|
|
286
|
+
const example = composedToolNames[0] || 'example_tool';
|
|
287
|
+
return `<!doctype html>
|
|
288
|
+
<html>
|
|
289
|
+
<head>
|
|
290
|
+
<meta charset="utf-8">
|
|
291
|
+
<title>${slug}</title>
|
|
292
|
+
<style>
|
|
293
|
+
:root { --bg:#FBFAF7; --fg:#1F2328; --muted:#8A8F98; --ok:#1A7F37; --err:#C0392B; --brand:#0891B2; --mono:ui-monospace,Menlo,monospace; }
|
|
294
|
+
body { margin:0; padding:20px 24px; background:var(--bg); color:var(--fg); font:14px/1.55 -apple-system,system-ui,sans-serif; }
|
|
295
|
+
h1 { font-size:17px; margin:0 0 2px; } .lede { color:var(--muted); font-size:12px; margin:0 0 14px; max-width:900px; }
|
|
296
|
+
.row { display:flex; gap:8px; align-items:center; margin:12px 0 12px; flex-wrap:wrap; }
|
|
297
|
+
input, button, select { font:inherit; padding:6px 10px; border:1px solid #D5D3CB; border-radius:4px; background:#fff; }
|
|
298
|
+
button { cursor:pointer; } button.danger { color:var(--err); }
|
|
299
|
+
#live { color:var(--muted); font-size:11px; }
|
|
300
|
+
#status { color:var(--muted); font-size:11px; min-height:16px; }
|
|
301
|
+
#status.err { color:var(--err); } #status.ok { color:var(--ok); }
|
|
302
|
+
.item { border-bottom:1px solid #F0EFEA; padding:10px 0; }
|
|
303
|
+
.idPill { display:inline-block; padding:1px 6px; border-radius:10px; font-size:10px; background:#F0EFEA; color:var(--muted); font-family:var(--mono); margin-right:6px; }
|
|
304
|
+
.topicPill { display:inline-block; padding:1px 7px; border-radius:10px; font-size:10px; background:#E0F2FE; color:var(--brand); margin-right:6px; }
|
|
305
|
+
.title { font-weight:600; }
|
|
306
|
+
.src { color:var(--muted); font-size:12px; word-break:break-all; }
|
|
307
|
+
.notes { color:#4a4d52; font-size:13px; margin-top:4px; }
|
|
308
|
+
.meta { color:var(--muted); font-size:11px; font-family:var(--mono); margin-top:2px; }
|
|
309
|
+
.btnDrop { float:right; font-size:11px; }
|
|
310
|
+
</style>
|
|
311
|
+
</head>
|
|
312
|
+
<body>
|
|
313
|
+
<h1>${slug} <span id="live">· connecting…</span></h1>
|
|
314
|
+
<p class="lede">Items the agent saves via <code>save_item</code> land here — live. Composed from <code>pi:${packageName}</code>. Cross-process pulse updates the panel even when the agent runs in another terminal.</p>
|
|
315
|
+
|
|
316
|
+
<div class="row">
|
|
317
|
+
<input id="topic" placeholder="Filter by topic…" />
|
|
318
|
+
<span id="count" class="muted"></span>
|
|
319
|
+
<span id="status" style="margin-left:auto"></span>
|
|
320
|
+
</div>
|
|
321
|
+
|
|
322
|
+
<div id="items"><p class="muted" style="padding:14px">loading…</p></div>
|
|
323
|
+
|
|
324
|
+
<script>
|
|
325
|
+
const token = new URLSearchParams(location.search).get('token') || '';
|
|
326
|
+
const PLUGIN = ${JSON.stringify(slug)};
|
|
327
|
+
|
|
328
|
+
function setStatus(msg, tone) {
|
|
329
|
+
const el = document.getElementById('status');
|
|
330
|
+
el.textContent = msg || ''; el.className = tone || '';
|
|
331
|
+
}
|
|
332
|
+
async function state(op, extra = {}) {
|
|
333
|
+
const res = await fetch('/api/plugin-state/' + PLUGIN, {
|
|
334
|
+
method: 'POST',
|
|
335
|
+
headers: { 'Content-Type': 'application/json', 'X-Bahulam-Local-Token': token },
|
|
336
|
+
body: JSON.stringify({ op, ...extra }),
|
|
337
|
+
});
|
|
338
|
+
const body = await res.json();
|
|
339
|
+
if (!res.ok || body.ok === false) throw new Error(body.error || body.message || 'state op failed');
|
|
340
|
+
return body.result;
|
|
341
|
+
}
|
|
342
|
+
async function tool(name, args = {}) {
|
|
343
|
+
const res = await fetch('/api/tools/execute', {
|
|
344
|
+
method: 'POST',
|
|
345
|
+
headers: { 'Content-Type': 'application/json', 'X-Bahulam-Local-Token': token },
|
|
346
|
+
body: JSON.stringify({ name, args }),
|
|
347
|
+
});
|
|
348
|
+
const body = await res.json();
|
|
349
|
+
if (!res.ok || body.ok === false) throw new Error(body.error || 'tool call failed');
|
|
350
|
+
if (body.result?.success === false) throw new Error(String(body.result.output));
|
|
351
|
+
return body.result;
|
|
352
|
+
}
|
|
353
|
+
function escapeHtml(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
|
354
|
+
function fmtTime(iso) { return (iso || '').replace('T', ' ').slice(0, 19); }
|
|
355
|
+
|
|
356
|
+
async function reload() {
|
|
357
|
+
const container = document.getElementById('items');
|
|
358
|
+
try {
|
|
359
|
+
const filter = document.getElementById('topic').value.trim().toLowerCase();
|
|
360
|
+
const rows = await state('list', { stream: 'items', limit: 500, order: 'desc' });
|
|
361
|
+
const items = (rows || []).filter(r => !filter || String(r.payload?.topic || '').toLowerCase() === filter);
|
|
362
|
+
document.getElementById('count').textContent = \`\${items.length} item\${items.length === 1 ? '' : 's'}\`;
|
|
363
|
+
if (!items.length) {
|
|
364
|
+
container.innerHTML = '<p class="muted" style="padding:14px">no items yet — ask the ${slug} agent</p>';
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
container.innerHTML = items.map(r => {
|
|
368
|
+
const p = r.payload || {};
|
|
369
|
+
return \`<div class="item">
|
|
370
|
+
<button class="btnDrop danger" data-drop="\${r.id}">Drop</button>
|
|
371
|
+
<span class="idPill">#\${r.id}</span>\${p.topic ? \`<span class="topicPill">\${escapeHtml(p.topic)}</span>\` : ''}
|
|
372
|
+
<span class="title">\${escapeHtml(p.title || '')}</span>
|
|
373
|
+
\${p.source ? \`<div class="src"><a href="\${escapeHtml(p.source)}" target="_blank" rel="noreferrer">\${escapeHtml(p.source)}</a></div>\` : ''}
|
|
374
|
+
\${p.notes ? \`<div class="notes">\${escapeHtml(p.notes)}</div>\` : ''}
|
|
375
|
+
<div class="meta">\${fmtTime(r.created_at || p.at)}</div>
|
|
376
|
+
</div>\`;
|
|
377
|
+
}).join('');
|
|
378
|
+
container.querySelectorAll('button[data-drop]').forEach(btn => {
|
|
379
|
+
btn.addEventListener('click', async () => {
|
|
380
|
+
try { await tool('drop_item', { id: Number(btn.getAttribute('data-drop')) }); setStatus('dropped', 'ok'); }
|
|
381
|
+
catch (err) { setStatus(err.message, 'err'); }
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
} catch (err) {
|
|
385
|
+
container.innerHTML = \`<p style="padding:14px;color:var(--err)">\${escapeHtml(err.message)}</p>\`;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
let _t = null;
|
|
390
|
+
const scheduleReload = () => { clearTimeout(_t); _t = setTimeout(reload, 200); };
|
|
391
|
+
document.getElementById('topic').addEventListener('input', scheduleReload);
|
|
392
|
+
|
|
393
|
+
try {
|
|
394
|
+
const es = new EventSource('/api/events?token=' + encodeURIComponent(token));
|
|
395
|
+
es.addEventListener('open', () => { document.getElementById('live').textContent = '· live'; });
|
|
396
|
+
es.addEventListener('error', () => { document.getElementById('live').textContent = '· reconnecting…'; });
|
|
397
|
+
es.addEventListener('plugin_state_changed', (ev) => {
|
|
398
|
+
let evt = {}; try { evt = JSON.parse(ev.data || '{}'); } catch {}
|
|
399
|
+
if (evt.plugin !== PLUGIN) return;
|
|
400
|
+
scheduleReload();
|
|
401
|
+
});
|
|
402
|
+
} catch { /* SSE unavailable; still load-only */ }
|
|
403
|
+
|
|
404
|
+
reload();
|
|
405
|
+
</script>
|
|
406
|
+
</body>
|
|
407
|
+
</html>
|
|
408
|
+
`;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Scaffold a full pack directory from an installed pi ingredient.
|
|
413
|
+
*
|
|
414
|
+
* @param {Object} opts
|
|
415
|
+
* @param {string} opts.packageName — pi package name (e.g. "pi-web-access")
|
|
416
|
+
* @param {string} [opts.versionRange] — pi package version range
|
|
417
|
+
* @param {string} opts.piDir — dir where pi package is installed (~/.bahulam/plugins-pi/<safe>/)
|
|
418
|
+
* @param {string} opts.targetDir — parent dir for the generated pack
|
|
419
|
+
* @param {Object} opts.discoveredTools — parsed .bahulam-tools.json
|
|
420
|
+
* @param {boolean} [opts.state=true] — include persistent state layer + native tools
|
|
421
|
+
* @param {boolean} [opts.workspace=true] — include reactive panel
|
|
422
|
+
* @param {string} [opts.slug] — override the derived slug
|
|
423
|
+
* @param {boolean} [opts.force=false] — overwrite an existing pack
|
|
424
|
+
* @returns {{ dest: string, slug: string, namespace: string, exposeTools: string[] }}
|
|
425
|
+
*/
|
|
426
|
+
export function scaffoldPiPack({
|
|
427
|
+
packageName,
|
|
428
|
+
versionRange,
|
|
429
|
+
piDir,
|
|
430
|
+
targetDir,
|
|
431
|
+
discoveredTools,
|
|
432
|
+
state = true,
|
|
433
|
+
workspace = true,
|
|
434
|
+
slug: slugOverride,
|
|
435
|
+
force = false,
|
|
436
|
+
}) {
|
|
437
|
+
const slug = slugOverride || deriveSlug(packageName);
|
|
438
|
+
const namespace = deriveNamespace(packageName);
|
|
439
|
+
const toolNames = (discoveredTools?.tools || []).map(t => t.name).filter(Boolean);
|
|
440
|
+
if (!toolNames.length) {
|
|
441
|
+
throw new Error(`pi package ${packageName} has no discoverable tools — cannot scaffold`);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const dest = path.join(targetDir, slug);
|
|
445
|
+
if (fs.existsSync(dest)) {
|
|
446
|
+
if (!force) throw new Error(`pack already exists: ${dest} (use --force to overwrite)`);
|
|
447
|
+
fs.rmSync(dest, { recursive: true, force: true });
|
|
448
|
+
}
|
|
449
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
450
|
+
|
|
451
|
+
const agentSlug = `${namespace}-specialist`;
|
|
452
|
+
const agentDescription = truncate(
|
|
453
|
+
`Specialist agent for ${packageName}. Composes ${toolNames.length} tool${toolNames.length === 1 ? '' : 's'} exposed as ${namespace}${COMPOSED_TOOL_SEPARATOR}*.`,
|
|
454
|
+
240,
|
|
455
|
+
);
|
|
456
|
+
const systemPrompt = generatePrompt(packageName, namespace, toolNames, state);
|
|
457
|
+
|
|
458
|
+
const manifest = renderManifest({
|
|
459
|
+
slug,
|
|
460
|
+
packageName,
|
|
461
|
+
versionRange,
|
|
462
|
+
namespace,
|
|
463
|
+
exposeTools: toolNames,
|
|
464
|
+
agentSlug,
|
|
465
|
+
agentDescription,
|
|
466
|
+
hasState: state,
|
|
467
|
+
hasWorkspace: workspace,
|
|
468
|
+
systemPrompt,
|
|
469
|
+
});
|
|
470
|
+
fs.writeFileSync(path.join(dest, 'plugin.yaml'), manifest);
|
|
471
|
+
|
|
472
|
+
if (state) {
|
|
473
|
+
const toolsDir = path.join(dest, 'tools');
|
|
474
|
+
fs.mkdirSync(toolsDir, { recursive: true });
|
|
475
|
+
fs.writeFileSync(path.join(toolsDir, 'save-item.mjs'), SAVE_ITEM_TOOL);
|
|
476
|
+
fs.writeFileSync(path.join(toolsDir, 'list-items.mjs'), LIST_ITEMS_TOOL);
|
|
477
|
+
fs.writeFileSync(path.join(toolsDir, 'drop-item.mjs'), DROP_ITEM_TOOL);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (workspace) {
|
|
481
|
+
const wsDir = path.join(dest, 'workspace');
|
|
482
|
+
fs.mkdirSync(wsDir, { recursive: true });
|
|
483
|
+
fs.writeFileSync(path.join(wsDir, 'panel.html'), renderPanel(slug, packageName, toolNames));
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
return { dest, slug, namespace, exposeTools: toolNames, agentSlug };
|
|
487
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pi runtime shim — the synthetic `pi` module a composed pi package
|
|
3
|
+
* imports at load time.
|
|
4
|
+
*
|
|
5
|
+
* Pi's extension API is imperative: extensions do `import { pi } from
|
|
6
|
+
* 'pi'` and call `pi.registerTool(name, schema, handler)` /
|
|
7
|
+
* `pi.registerCommand(cmd, handler)` / `pi.ctx.ui.setWidget(...)`. We
|
|
8
|
+
* intercept the module resolution via a Node ESM loader hook
|
|
9
|
+
* (`loader-hook.mjs`) that returns a virtual module which imports THIS
|
|
10
|
+
* shim and instantiates it against a shared capture object.
|
|
11
|
+
*
|
|
12
|
+
* v1 scope:
|
|
13
|
+
* - registerTool: captured, exposed to our loop as a pluginToolMap entry
|
|
14
|
+
* - registerCommand: captured but not surfaced (no REPL command bridge)
|
|
15
|
+
* - pi.events.on/emit: no-op (cross-extension event bus, deferred)
|
|
16
|
+
* - pi.ctx.ui.setWidget/custom: no-op with debug warning (TUI widgets
|
|
17
|
+
* don't translate to our workspace canvas; author dedicated panels)
|
|
18
|
+
* - pi.ctx.log: forwards to stderr with plugin prefix
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export function createPiShim({ pluginName = 'pi', captured }) {
|
|
22
|
+
if (!captured || typeof captured !== 'object') {
|
|
23
|
+
throw new Error('createPiShim: captured object is required');
|
|
24
|
+
}
|
|
25
|
+
captured.tools ||= [];
|
|
26
|
+
captured.commands ||= [];
|
|
27
|
+
|
|
28
|
+
const pi = {
|
|
29
|
+
// Pi's canonical shape is registerTool({name, description, parameters,
|
|
30
|
+
// execute}) — a single descriptor with an `execute` function. Older
|
|
31
|
+
// examples use registerTool(name, schema, handler) with positional args.
|
|
32
|
+
// Accept both.
|
|
33
|
+
registerTool(arg1, arg2, arg3) {
|
|
34
|
+
if (arg1 && typeof arg1 === 'object' && !Array.isArray(arg1)) {
|
|
35
|
+
const desc = arg1;
|
|
36
|
+
const name = desc.name;
|
|
37
|
+
if (!name || typeof name !== 'string') return;
|
|
38
|
+
// Descriptor form: pi's canonical `{name, description, parameters,
|
|
39
|
+
// execute}`. Handler is called as execute(id, params).
|
|
40
|
+
captured.tools.push({
|
|
41
|
+
name,
|
|
42
|
+
description: desc.description || '',
|
|
43
|
+
schema: desc.parameters || desc.input_schema || desc.schema || { type: 'object', properties: {} },
|
|
44
|
+
handler: typeof desc.execute === 'function' ? desc.execute
|
|
45
|
+
: typeof desc.handler === 'function' ? desc.handler
|
|
46
|
+
: typeof desc.call === 'function' ? desc.call
|
|
47
|
+
: null,
|
|
48
|
+
_form: 'descriptor',
|
|
49
|
+
});
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
// Positional legacy form: (name, schema, handler). Handler is
|
|
53
|
+
// called as handler(args).
|
|
54
|
+
if (!arg1 || typeof arg1 !== 'string') return;
|
|
55
|
+
let name = arg1, schema = arg2, handler = arg3;
|
|
56
|
+
if (typeof handler !== 'function' && typeof schema === 'function') {
|
|
57
|
+
handler = schema;
|
|
58
|
+
schema = { type: 'object', properties: {} };
|
|
59
|
+
}
|
|
60
|
+
captured.tools.push({
|
|
61
|
+
name,
|
|
62
|
+
description: '',
|
|
63
|
+
schema: schema || { type: 'object', properties: {} },
|
|
64
|
+
handler,
|
|
65
|
+
_form: 'positional',
|
|
66
|
+
});
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
// registerCommand(cmd, descriptor) in real pi; descriptor has
|
|
70
|
+
// {description, execute}. Older form: registerCommand(cmd, handler).
|
|
71
|
+
registerCommand(cmd, arg2) {
|
|
72
|
+
if (!cmd || typeof cmd !== 'string') return;
|
|
73
|
+
if (arg2 && typeof arg2 === 'object' && !Array.isArray(arg2)) {
|
|
74
|
+
const desc = arg2;
|
|
75
|
+
captured.commands.push({
|
|
76
|
+
cmd,
|
|
77
|
+
description: desc.description || '',
|
|
78
|
+
handler: typeof desc.execute === 'function' ? desc.execute
|
|
79
|
+
: typeof desc.handler === 'function' ? desc.handler
|
|
80
|
+
: null,
|
|
81
|
+
});
|
|
82
|
+
} else if (typeof arg2 === 'function') {
|
|
83
|
+
captured.commands.push({ cmd, handler: arg2 });
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
events: {
|
|
88
|
+
on() { /* no-op in v1 */ },
|
|
89
|
+
emit() { /* no-op in v1 */ },
|
|
90
|
+
off() { /* no-op in v1 */ },
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
// Pi packages call pi.on(...) directly for lifecycle events (session
|
|
94
|
+
// start/end etc.) — no-op them so activation reaches registerTool.
|
|
95
|
+
// Same treatment for pi.off, pi.emit, pi.once.
|
|
96
|
+
on() { /* no-op */ },
|
|
97
|
+
off() { /* no-op */ },
|
|
98
|
+
emit() { /* no-op */ },
|
|
99
|
+
once() { /* no-op */ },
|
|
100
|
+
|
|
101
|
+
// Additional pi surfaces called at activation-time by real packages
|
|
102
|
+
// (pi-web-access, etc.). Stub them so activation completes and tools
|
|
103
|
+
// register; runtime callers that rely on these still throw at call
|
|
104
|
+
// time, which is the correct signal that a feature isn't supported.
|
|
105
|
+
registerShortcut() { /* no-op */ },
|
|
106
|
+
appendEntry() { /* no-op */ },
|
|
107
|
+
sendMessage() { /* no-op */ },
|
|
108
|
+
exec() {
|
|
109
|
+
throw new Error(`[pi:${pluginName}] pi.exec is not supported in Bahulam compat`);
|
|
110
|
+
},
|
|
111
|
+
// pi.fetch is pi's authenticated fetch. Delegate to global fetch —
|
|
112
|
+
// that's what the extension expects: an HTTP client. Auth headers
|
|
113
|
+
// are typically added by the extension itself using env credentials.
|
|
114
|
+
fetch(...args) { return globalThis.fetch(...args); },
|
|
115
|
+
|
|
116
|
+
ctx: {
|
|
117
|
+
ui: {
|
|
118
|
+
setWidget(widget) {
|
|
119
|
+
if (process.env.DEBUG) {
|
|
120
|
+
const title = widget?.title || widget?.name || 'untitled';
|
|
121
|
+
process.stderr.write(`[pi:${pluginName}] widget ignored: ${title}\n`);
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
custom() { /* no-op */ },
|
|
125
|
+
clear() { /* no-op */ },
|
|
126
|
+
},
|
|
127
|
+
log(...args) {
|
|
128
|
+
process.stderr.write(`[pi:${pluginName}] ${args.map(String).join(' ')}\n`);
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
return pi;
|
|
134
|
+
}
|