@shawnstack/quickforge 1.3.24 → 1.3.26
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/README.md +24 -18
- package/dist/assets/anthropic-BcnDL7hi.js +39 -0
- package/dist/assets/azure-openai-responses-BEfdv0qd.js +1 -0
- package/dist/assets/google-C2y985rW.js +1 -0
- package/dist/assets/google-shared-Cqjw1plk.js +11 -0
- package/dist/assets/google-vertex-Jf9zNsCF.js +1 -0
- package/dist/assets/{icons-DmRYmmql.js → icons-BVM5--R9.js} +1 -1
- package/dist/assets/{index-s72bxhrh.js → index-8Q1Ovled.js} +604 -550
- package/dist/assets/index-ZYbEKGUp.css +3 -0
- package/dist/assets/{mistral-DCZ8VphX.js → mistral-qYbgRY3z.js} +1 -1
- package/dist/assets/openai-codex-responses--aAgyYJM.js +7 -0
- package/dist/assets/openai-completions-CHDluyXM.js +5 -0
- package/dist/assets/openai-prompt-cache-CErE62Yt.js +1 -0
- package/dist/assets/openai-responses-UtRriBXu.js +1 -0
- package/dist/assets/{openai-responses-shared-RzgnIlMf.js → openai-responses-shared-G6WDDqJ8.js} +1 -1
- package/dist/assets/openrouter-Dz9zwzUG.js +1 -0
- package/dist/assets/{react-vendor-BsV2HYbc.js → react-vendor-DAoL5p8_.js} +1 -1
- package/dist/assets/sanitize-unicode-BhyPmlyt.js +1 -0
- package/dist/assets/transform-messages-Dhj_4OTw.js +1 -0
- package/dist/index.html +4 -4
- package/package.json +4 -3
- package/server/agent-manager.mjs +162 -176
- package/server/ai-http-logger.mjs +20 -5
- package/server/approval-store.mjs +63 -0
- package/server/custom-commands.mjs +67 -9
- package/server/index.mjs +7 -0
- package/server/message-converters.mjs +79 -0
- package/server/plugins/loader.mjs +56 -0
- package/server/plugins/manifest.mjs +174 -0
- package/server/plugins/registry.mjs +304 -0
- package/server/project-config.mjs +53 -4
- package/server/routes/agent-profiles.mjs +1 -1
- package/server/routes/agent.mjs +1 -16
- package/server/routes/filesystem.mjs +18 -2
- package/server/routes/plugins.mjs +63 -0
- package/server/routes/project.mjs +2 -0
- package/server/routes/scheduled-tasks.mjs +1 -1
- package/server/routes/storage.mjs +66 -31
- package/server/routes/tools.mjs +12 -1
- package/server/session-utils.mjs +1 -1
- package/server/skills.mjs +64 -5
- package/server/storage.mjs +91 -8
- package/server/system-prompt.mjs +27 -5
- package/server/tool-wiring.mjs +113 -0
- package/server/utils/workspace.mjs +20 -1
- package/dist/assets/anthropic-BrbLtQkg.js +0 -39
- package/dist/assets/azure-openai-responses-q9QFpQk3.js +0 -1
- package/dist/assets/google-Bv6IeSRf.js +0 -1
- package/dist/assets/google-shared-CLc4ziON.js +0 -11
- package/dist/assets/google-vertex-Cwpe8vbn.js +0 -1
- package/dist/assets/index-C4m48ndP.css +0 -3
- package/dist/assets/openai-codex-responses-Bx7iyHzd.js +0 -7
- package/dist/assets/openai-completions-CihVV11E.js +0 -5
- package/dist/assets/openai-responses-BigEdUNS.js +0 -1
- package/dist/assets/transform-messages-CmnxG9RB.js +0 -1
- /package/dist/assets/{hash-Bt1aVMQ3.js → hash-kZ2KD_no.js} +0 -0
- /package/dist/assets/{openai-Cn7eGqwa.js → openai-Bf1npfRy.js} +0 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import { dataDir, readStore, atomicUpdate } from '../storage.mjs'
|
|
6
|
+
import { logger } from '../utils/logger.mjs'
|
|
7
|
+
import { loadPlugin } from './loader.mjs'
|
|
8
|
+
import {
|
|
9
|
+
isPluginToolName,
|
|
10
|
+
normalizePluginManifest,
|
|
11
|
+
parseQuickForgePluginToolName,
|
|
12
|
+
quickForgePluginToolName,
|
|
13
|
+
} from './manifest.mjs'
|
|
14
|
+
|
|
15
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
16
|
+
const bundledPluginDir = path.resolve(__dirname, '..', '..', 'plugins')
|
|
17
|
+
const globalPluginDir = path.join(dataDir, 'plugins')
|
|
18
|
+
const legacyGlobalPluginDir = path.join(os.homedir(), '.agents', 'plugins')
|
|
19
|
+
let cachedCatalog = null
|
|
20
|
+
let cachedCatalogKey = null
|
|
21
|
+
let refreshPromise = null
|
|
22
|
+
|
|
23
|
+
function catalogKey(projectContext = null) {
|
|
24
|
+
return projectContext?.workspaceRoot ? path.resolve(projectContext.workspaceRoot) : ''
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isPlainObject(value) {
|
|
28
|
+
return Boolean(value && typeof value === 'object' && !Array.isArray(value))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function exists(filePath) {
|
|
32
|
+
try {
|
|
33
|
+
await fs.access(filePath)
|
|
34
|
+
return true
|
|
35
|
+
} catch {
|
|
36
|
+
return false
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function readJsonFile(filePath) {
|
|
41
|
+
const text = await fs.readFile(filePath, 'utf8')
|
|
42
|
+
return JSON.parse(text.trimStart())
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function listPluginDirs(root) {
|
|
46
|
+
let entries = []
|
|
47
|
+
try {
|
|
48
|
+
entries = await fs.readdir(root, { withFileTypes: true })
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (error?.code === 'ENOENT') return []
|
|
51
|
+
throw error
|
|
52
|
+
}
|
|
53
|
+
return entries.filter((entry) => entry.isDirectory()).map((entry) => path.join(root, entry.name))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function normalizePluginStore(store) {
|
|
57
|
+
const source = isPlainObject(store) ? store : {}
|
|
58
|
+
return {
|
|
59
|
+
enabled: isPlainObject(source.enabled) ? source.enabled : {},
|
|
60
|
+
config: isPlainObject(source.config) ? source.config : {},
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function readPluginStore() {
|
|
65
|
+
return normalizePluginStore(await readStore('plugins'))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function contributionSummary(contribution) {
|
|
69
|
+
return {
|
|
70
|
+
path: contribution.path,
|
|
71
|
+
resolvedPath: contribution.resolvedPath,
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function discoverManifests(projectContext) {
|
|
76
|
+
const roots = [bundledPluginDir, globalPluginDir, legacyGlobalPluginDir]
|
|
77
|
+
if (projectContext?.workspaceRoot) roots.push(path.join(projectContext.workspaceRoot, '.quickforge', 'plugins'))
|
|
78
|
+
|
|
79
|
+
const manifests = new Map()
|
|
80
|
+
const errors = []
|
|
81
|
+
for (const root of roots) {
|
|
82
|
+
const dirs = await listPluginDirs(root)
|
|
83
|
+
for (const dir of dirs) {
|
|
84
|
+
const manifestPath = path.join(dir, 'plugin.json')
|
|
85
|
+
if (!(await exists(manifestPath))) continue
|
|
86
|
+
try {
|
|
87
|
+
const manifest = normalizePluginManifest(await readJsonFile(manifestPath), dir)
|
|
88
|
+
if (!manifests.has(manifest.name) || root !== legacyGlobalPluginDir) {
|
|
89
|
+
manifests.set(manifest.name, { manifest, sourceRoot: root })
|
|
90
|
+
}
|
|
91
|
+
} catch (error) {
|
|
92
|
+
errors.push({ dir, sourceRoot: root, error: error?.message || 'Failed to read plugin manifest' })
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return { manifests, errors, roots }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function loadEnabledPlugins(projectContext) {
|
|
100
|
+
const store = await readPluginStore()
|
|
101
|
+
const { manifests, errors, roots } = await discoverManifests(projectContext)
|
|
102
|
+
const plugins = []
|
|
103
|
+
const handlers = new Map()
|
|
104
|
+
|
|
105
|
+
for (const { manifest, sourceRoot } of manifests.values()) {
|
|
106
|
+
const enabled = store.enabled[manifest.name] === true || (manifest.enabledByDefault && store.enabled[manifest.name] !== false)
|
|
107
|
+
const config = isPlainObject(store.config[manifest.name]) ? store.config[manifest.name] : {}
|
|
108
|
+
const entry = {
|
|
109
|
+
...manifest,
|
|
110
|
+
enabled,
|
|
111
|
+
config,
|
|
112
|
+
sourceRoot,
|
|
113
|
+
status: enabled ? 'loaded' : 'disabled',
|
|
114
|
+
error: null,
|
|
115
|
+
tools: manifest.contributes.tools.map((tool) => ({
|
|
116
|
+
...tool,
|
|
117
|
+
quickForgeName: quickForgePluginToolName(manifest.name, tool.name),
|
|
118
|
+
})),
|
|
119
|
+
skills: manifest.contributes.skills.map(contributionSummary),
|
|
120
|
+
commands: manifest.contributes.commands.map(contributionSummary),
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (enabled && entry.tools.length > 0) {
|
|
124
|
+
try {
|
|
125
|
+
const loaded = await loadPlugin(manifest, { config, projectContext })
|
|
126
|
+
handlers.set(manifest.name, loaded)
|
|
127
|
+
} catch (error) {
|
|
128
|
+
logger.error(`Failed to load plugin ${manifest.name}:`, error)
|
|
129
|
+
entry.status = 'error'
|
|
130
|
+
entry.error = error?.message || 'Failed to load plugin'
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
plugins.push(entry)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
plugins.sort((a, b) => a.name.localeCompare(b.name))
|
|
138
|
+
return { plugins, handlers, errors, roots }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function refreshPlugins(projectContext = null) {
|
|
142
|
+
const key = catalogKey(projectContext)
|
|
143
|
+
if (!refreshPromise) {
|
|
144
|
+
refreshPromise = loadEnabledPlugins(projectContext).then((catalog) => {
|
|
145
|
+
cachedCatalog = catalog
|
|
146
|
+
cachedCatalogKey = key
|
|
147
|
+
return catalog
|
|
148
|
+
}).finally(() => {
|
|
149
|
+
refreshPromise = null
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
return refreshPromise
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function getCatalog(projectContext = null) {
|
|
156
|
+
const key = catalogKey(projectContext)
|
|
157
|
+
if (!cachedCatalog || cachedCatalogKey !== key) return refreshPlugins(projectContext)
|
|
158
|
+
return cachedCatalog
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function enabledLoadedPlugins(catalog) {
|
|
162
|
+
return catalog.plugins.filter((plugin) => plugin.enabled && plugin.status === 'loaded')
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function getEnabledPluginSkillSources(projectContext = null) {
|
|
166
|
+
const catalog = await getCatalog(projectContext)
|
|
167
|
+
return enabledLoadedPlugins(catalog).flatMap((plugin) => plugin.skills.map((skill) => ({
|
|
168
|
+
pluginName: plugin.name,
|
|
169
|
+
source: `plugin:${plugin.name}`,
|
|
170
|
+
dir: skill.resolvedPath,
|
|
171
|
+
path: skill.path,
|
|
172
|
+
})))
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function getEnabledPluginCommandSources(projectContext = null) {
|
|
176
|
+
const catalog = await getCatalog(projectContext)
|
|
177
|
+
return enabledLoadedPlugins(catalog).flatMap((plugin) => plugin.commands.map((command) => ({
|
|
178
|
+
pluginName: plugin.name,
|
|
179
|
+
source: `plugin:${plugin.name}`,
|
|
180
|
+
path: command.resolvedPath,
|
|
181
|
+
relativePath: command.path,
|
|
182
|
+
})))
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export async function getPluginStatus(projectContext = null) {
|
|
186
|
+
const catalog = await refreshPlugins(projectContext)
|
|
187
|
+
return {
|
|
188
|
+
searchPaths: catalog.roots,
|
|
189
|
+
errors: catalog.errors,
|
|
190
|
+
plugins: catalog.plugins.map((plugin) => ({
|
|
191
|
+
name: plugin.name,
|
|
192
|
+
displayName: plugin.displayName,
|
|
193
|
+
version: plugin.version,
|
|
194
|
+
description: plugin.description,
|
|
195
|
+
apiVersion: plugin.apiVersion,
|
|
196
|
+
quickforgeVersion: plugin.quickforgeVersion,
|
|
197
|
+
enabledByDefault: plugin.enabledByDefault,
|
|
198
|
+
dir: plugin.dir,
|
|
199
|
+
sourceRoot: plugin.sourceRoot,
|
|
200
|
+
enabled: plugin.enabled,
|
|
201
|
+
status: plugin.status,
|
|
202
|
+
error: plugin.error,
|
|
203
|
+
permissions: plugin.permissions,
|
|
204
|
+
tools: plugin.tools.map((tool) => ({
|
|
205
|
+
name: tool.name,
|
|
206
|
+
quickForgeName: tool.quickForgeName,
|
|
207
|
+
label: tool.label,
|
|
208
|
+
description: tool.description,
|
|
209
|
+
})),
|
|
210
|
+
skills: plugin.skills.map((skill) => ({ path: skill.path })),
|
|
211
|
+
commands: plugin.commands.map((command) => ({ path: command.path })),
|
|
212
|
+
settings: plugin.contributes.settings,
|
|
213
|
+
config: plugin.config,
|
|
214
|
+
})),
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export async function setPluginEnabled(name, enabled) {
|
|
219
|
+
await atomicUpdate('plugins', (store) => {
|
|
220
|
+
const next = normalizePluginStore(store)
|
|
221
|
+
next.enabled[name] = enabled === true
|
|
222
|
+
return next
|
|
223
|
+
})
|
|
224
|
+
cachedCatalog = null
|
|
225
|
+
cachedCatalogKey = null
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export async function setPluginConfig(name, config) {
|
|
229
|
+
await atomicUpdate('plugins', (store) => {
|
|
230
|
+
const next = normalizePluginStore(store)
|
|
231
|
+
next.config[name] = isPlainObject(config) ? config : {}
|
|
232
|
+
return next
|
|
233
|
+
})
|
|
234
|
+
cachedCatalog = null
|
|
235
|
+
cachedCatalogKey = null
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export async function createPluginToolDefinitions(projectContext = null) {
|
|
239
|
+
const catalog = await getCatalog(projectContext)
|
|
240
|
+
const definitions = []
|
|
241
|
+
for (const plugin of catalog.plugins) {
|
|
242
|
+
if (!plugin.enabled || plugin.status !== 'loaded') continue
|
|
243
|
+
for (const tool of plugin.tools) {
|
|
244
|
+
definitions.push({
|
|
245
|
+
name: tool.quickForgeName,
|
|
246
|
+
label: tool.label,
|
|
247
|
+
description: `[Plugin:${plugin.name}] ${tool.description || tool.name}`,
|
|
248
|
+
parameters: tool.parameters,
|
|
249
|
+
executionMode: tool.executionMode,
|
|
250
|
+
plugin: { name: plugin.name, toolName: tool.name },
|
|
251
|
+
})
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return definitions
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export { isPluginToolName }
|
|
258
|
+
|
|
259
|
+
export async function callPluginTool(toolName, params = {}, toolContext = {}) {
|
|
260
|
+
const parsed = parseQuickForgePluginToolName(toolName)
|
|
261
|
+
if (!parsed) {
|
|
262
|
+
const error = new Error(`Invalid plugin tool name: ${toolName}`)
|
|
263
|
+
error.statusCode = 400
|
|
264
|
+
throw error
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const catalog = await getCatalog(toolContext)
|
|
268
|
+
const plugin = catalog.plugins.find((item) => item.name === parsed.pluginName)
|
|
269
|
+
if (!plugin || !plugin.enabled || plugin.status !== 'loaded') {
|
|
270
|
+
const error = new Error(`Plugin is not loaded: ${parsed.pluginName}`)
|
|
271
|
+
error.statusCode = 503
|
|
272
|
+
throw error
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const tool = plugin.tools.find((item) => item.name === parsed.toolName || item.quickForgeName === toolName)
|
|
276
|
+
if (!tool) {
|
|
277
|
+
const error = new Error(`Unknown plugin tool: ${toolName}`)
|
|
278
|
+
error.statusCode = 404
|
|
279
|
+
throw error
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const handler = catalog.handlers.get(parsed.pluginName)
|
|
283
|
+
if (!handler) {
|
|
284
|
+
const error = new Error(`Missing plugin handler: ${parsed.pluginName}`)
|
|
285
|
+
error.statusCode = 503
|
|
286
|
+
throw error
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const result = await handler.callTool(tool.name, params, {
|
|
290
|
+
...toolContext,
|
|
291
|
+
plugin: { name: plugin.name, dir: plugin.dir, config: plugin.config },
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
isError: Boolean(result.isError),
|
|
296
|
+
content: result.content,
|
|
297
|
+
details: {
|
|
298
|
+
...(isPlainObject(result.details) ? result.details : {}),
|
|
299
|
+
plugin: true,
|
|
300
|
+
pluginName: plugin.name,
|
|
301
|
+
tool: tool.name,
|
|
302
|
+
},
|
|
303
|
+
}
|
|
304
|
+
}
|
|
@@ -298,24 +298,71 @@ export async function readInstructionsFile(filePath) {
|
|
|
298
298
|
return null
|
|
299
299
|
}
|
|
300
300
|
|
|
301
|
+
async function readInstructionSources(candidates) {
|
|
302
|
+
const sources = []
|
|
303
|
+
const seen = new Set()
|
|
304
|
+
|
|
305
|
+
for (const candidate of candidates) {
|
|
306
|
+
const file = path.resolve(candidate.file)
|
|
307
|
+
if (seen.has(file)) continue
|
|
308
|
+
seen.add(file)
|
|
309
|
+
|
|
310
|
+
try {
|
|
311
|
+
const content = await fs.readFile(file, 'utf8')
|
|
312
|
+
const trimmed = content.trim()
|
|
313
|
+
if (trimmed) sources.push({ source: candidate.source, content: trimmed })
|
|
314
|
+
} catch {
|
|
315
|
+
// optional compatibility source
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return sources
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function combineInstructionSources(sources) {
|
|
323
|
+
return sources.map((source) => source.content).join('\n\n') || null
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function globalInstructionCandidates() {
|
|
327
|
+
const home = os.homedir()
|
|
328
|
+
return [
|
|
329
|
+
{ file: path.join(home, '.claude', 'CLAUDE.md'), source: '~/.claude/CLAUDE.md' },
|
|
330
|
+
{ file: path.join(home, '.opencode', 'AGENTS.md'), source: '~/.opencode/AGENTS.md' },
|
|
331
|
+
{ file: path.join(dataDir, 'AGENTS.md'), source: '~/.quickforge/AGENTS.md' },
|
|
332
|
+
{ file: path.join(dataDir, 'agents.md'), source: '~/.quickforge/agents.md' },
|
|
333
|
+
]
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function projectInstructionCandidates(workspaceRoot) {
|
|
337
|
+
return [
|
|
338
|
+
{ file: path.join(workspaceRoot, 'CLAUDE.md'), source: 'CLAUDE.md' },
|
|
339
|
+
{ file: path.join(workspaceRoot, 'AGENTS.md'), source: 'AGENTS.md' },
|
|
340
|
+
{ file: path.join(workspaceRoot, 'agents.md'), source: 'agents.md' },
|
|
341
|
+
{ file: path.join(workspaceRoot, '.opencode', 'AGENTS.md'), source: '.opencode/AGENTS.md' },
|
|
342
|
+
{ file: path.join(workspaceRoot, '.quickforge', 'AGENTS.md'), source: '.quickforge/AGENTS.md' },
|
|
343
|
+
]
|
|
344
|
+
}
|
|
345
|
+
|
|
301
346
|
export async function buildInstructionsPayload(projectId) {
|
|
302
347
|
const config = await readProjectConfig()
|
|
303
|
-
let
|
|
348
|
+
let projectInstructionSources = []
|
|
304
349
|
let project = projectId ? config.projects.find((item) => item.id === projectId) ?? null : null
|
|
305
350
|
|
|
306
351
|
if (projectId) {
|
|
307
352
|
try {
|
|
308
353
|
const context = await projectContextFromId(projectId)
|
|
309
354
|
project = context.project
|
|
310
|
-
|
|
355
|
+
projectInstructionSources = await readInstructionSources(projectInstructionCandidates(context.workspaceRoot))
|
|
311
356
|
} catch {
|
|
312
357
|
// project not found or inaccessible — leave projectInstructions null
|
|
313
358
|
}
|
|
314
359
|
}
|
|
315
360
|
|
|
316
|
-
const
|
|
361
|
+
const globalInstructionSources = await readInstructionSources(globalInstructionCandidates())
|
|
362
|
+
const globalInstructions = combineInstructionSources(globalInstructionSources)
|
|
363
|
+
const projectInstructions = combineInstructionSources(projectInstructionSources)
|
|
317
364
|
const globalSkills = await loadSelectedGlobalSkills(config.globalSkills)
|
|
318
|
-
const projectSkills = project?.
|
|
365
|
+
const projectSkills = project?.path
|
|
319
366
|
? await loadSelectedProjectSkills(project.skills, project.path)
|
|
320
367
|
: []
|
|
321
368
|
const activeSkills = mergeSkills(globalSkills, projectSkills)
|
|
@@ -331,6 +378,8 @@ export async function buildInstructionsPayload(projectId) {
|
|
|
331
378
|
: null,
|
|
332
379
|
global: globalInstructions,
|
|
333
380
|
project: projectInstructions,
|
|
381
|
+
globalSources: globalInstructionSources,
|
|
382
|
+
projectSources: projectInstructionSources,
|
|
334
383
|
globalSkills: globalSkills.map(stripRuntimeFields),
|
|
335
384
|
projectSkills: projectSkills.map(stripRuntimeFields),
|
|
336
385
|
skills: activeSkills.map(stripRuntimeFields),
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { streamSimple } from '@
|
|
1
|
+
import { streamSimple } from '@earendil-works/pi-ai'
|
|
2
2
|
import { sendJson, readJsonBody, decodeSegment } from '../utils/response.mjs'
|
|
3
3
|
import { readStore } from '../storage.mjs'
|
|
4
4
|
import { logger } from '../utils/logger.mjs'
|
package/server/routes/agent.mjs
CHANGED
|
@@ -23,7 +23,6 @@ import {
|
|
|
23
23
|
approveAutoCompact,
|
|
24
24
|
rejectAutoCompact,
|
|
25
25
|
abortToolCall,
|
|
26
|
-
replaceSessionMessages,
|
|
27
26
|
rollbackSessionMessages,
|
|
28
27
|
continueSession,
|
|
29
28
|
agentEvents,
|
|
@@ -83,7 +82,7 @@ export async function handleAgentApi(req, res, url) {
|
|
|
83
82
|
error.statusCode = 400
|
|
84
83
|
throw error
|
|
85
84
|
}
|
|
86
|
-
const result = await runPrompt(sessionId, message)
|
|
85
|
+
const result = await runPrompt(sessionId, message, body?.selectedCapabilities)
|
|
87
86
|
sendJson(res, 200, result)
|
|
88
87
|
return
|
|
89
88
|
}
|
|
@@ -169,20 +168,6 @@ export async function handleAgentApi(req, res, url) {
|
|
|
169
168
|
return
|
|
170
169
|
}
|
|
171
170
|
|
|
172
|
-
// POST /api/agents/:sessionId/messages — replace session messages (legacy rollback/sync)
|
|
173
|
-
if (req.method === 'POST' && subPath === 'messages') {
|
|
174
|
-
const body = await readJsonBody(req)
|
|
175
|
-
const messages = body?.messages
|
|
176
|
-
if (!Array.isArray(messages)) {
|
|
177
|
-
const error = new Error('Missing messages array in request body')
|
|
178
|
-
error.statusCode = 400
|
|
179
|
-
throw error
|
|
180
|
-
}
|
|
181
|
-
const state = await replaceSessionMessages(sessionId, messages)
|
|
182
|
-
sendJson(res, 200, { ok: true, messages: state?.messages })
|
|
183
|
-
return
|
|
184
|
-
}
|
|
185
|
-
|
|
186
171
|
// POST /api/agents/:sessionId/rollback — roll back from a message index on the authoritative server state
|
|
187
172
|
if (req.method === 'POST' && subPath === 'rollback') {
|
|
188
173
|
const body = await readJsonBody(req)
|
|
@@ -50,9 +50,21 @@ async function getFilesystemRoots() {
|
|
|
50
50
|
return roots
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
async function listFilesystemDirectories(inputPath) {
|
|
53
|
+
async function listFilesystemDirectories(inputPath, allowedRoots) {
|
|
54
54
|
const requestedPath = String(inputPath || os.homedir())
|
|
55
55
|
const resolved = path.resolve(requestedPath)
|
|
56
|
+
|
|
57
|
+
// Only allow browsing within or at known filesystem roots
|
|
58
|
+
const isAllowed = allowedRoots.some((root) => {
|
|
59
|
+
const rel = path.relative(root, resolved)
|
|
60
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))
|
|
61
|
+
})
|
|
62
|
+
if (!isAllowed) {
|
|
63
|
+
const error = new Error('Access denied: path is outside allowed roots')
|
|
64
|
+
error.statusCode = 403
|
|
65
|
+
throw error
|
|
66
|
+
}
|
|
67
|
+
|
|
56
68
|
await assertDirectory(resolved)
|
|
57
69
|
|
|
58
70
|
const entries = await fs.readdir(resolved, { withFileTypes: true }).catch((error) => {
|
|
@@ -77,7 +89,11 @@ export async function handleFilesystemApi(req, res, url) {
|
|
|
77
89
|
}
|
|
78
90
|
|
|
79
91
|
if (req.method === 'GET' && url.pathname === '/api/filesystem/directories') {
|
|
80
|
-
|
|
92
|
+
const roots = await getFilesystemRoots()
|
|
93
|
+
const allowedRootPaths = roots.map((r) => path.resolve(r.path))
|
|
94
|
+
// Always allow browsing from home directory as a fallback
|
|
95
|
+
allowedRootPaths.push(os.homedir())
|
|
96
|
+
sendJson(res, 200, await listFilesystemDirectories(url.searchParams.get('path'), allowedRootPaths))
|
|
81
97
|
return
|
|
82
98
|
}
|
|
83
99
|
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { sendJson, readJsonBody, decodeSegment } from '../utils/response.mjs'
|
|
2
|
+
import { refreshAllSessionTools } from '../agent-manager.mjs'
|
|
3
|
+
import { projectContextFromId, readProjectConfig } from '../project-config.mjs'
|
|
4
|
+
import { getPluginStatus, refreshPlugins, setPluginConfig, setPluginEnabled } from '../plugins/registry.mjs'
|
|
5
|
+
|
|
6
|
+
async function activeProjectContext() {
|
|
7
|
+
const config = await readProjectConfig()
|
|
8
|
+
const activeProject = config.projects.find((project) => project.id === config.activeProjectId) || config.projects[0]
|
|
9
|
+
if (!activeProject?.id) return null
|
|
10
|
+
return projectContextFromId(activeProject.id)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function refreshPluginsAndAgentTools() {
|
|
14
|
+
const projectContext = await activeProjectContext()
|
|
15
|
+
await refreshPlugins(projectContext)
|
|
16
|
+
const refreshedSessions = await refreshAllSessionTools()
|
|
17
|
+
return { ...(await getPluginStatus(projectContext)), refreshedSessions }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function handlePluginsApi(req, res, url) {
|
|
21
|
+
const parts = url.pathname.split('/').filter(Boolean)
|
|
22
|
+
|
|
23
|
+
if (req.method === 'GET' && url.pathname === '/api/plugins') {
|
|
24
|
+
sendJson(res, 200, await getPluginStatus(await activeProjectContext()))
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (req.method === 'POST' && url.pathname === '/api/plugins/reload') {
|
|
29
|
+
sendJson(res, 200, await refreshPluginsAndAgentTools())
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (req.method === 'GET' && parts[0] === 'api' && parts[1] === 'plugins' && parts[2]) {
|
|
34
|
+
const name = decodeSegment(parts[2])
|
|
35
|
+
const status = await getPluginStatus(await activeProjectContext())
|
|
36
|
+
const plugin = status.plugins.find((item) => item.name === name)
|
|
37
|
+
if (!plugin) {
|
|
38
|
+
const error = new Error(`Unknown plugin: ${name}`)
|
|
39
|
+
error.statusCode = 404
|
|
40
|
+
throw error
|
|
41
|
+
}
|
|
42
|
+
sendJson(res, 200, { ...status, plugin })
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (req.method === 'PUT' && parts[0] === 'api' && parts[1] === 'plugins' && parts[2] && parts[3] === 'enabled') {
|
|
47
|
+
const body = await readJsonBody(req)
|
|
48
|
+
await setPluginEnabled(decodeSegment(parts[2]), body?.enabled === true)
|
|
49
|
+
sendJson(res, 200, await refreshPluginsAndAgentTools())
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (req.method === 'PUT' && parts[0] === 'api' && parts[1] === 'plugins' && parts[2] && parts[3] === 'config') {
|
|
54
|
+
const body = await readJsonBody(req)
|
|
55
|
+
await setPluginConfig(decodeSegment(parts[2]), body?.config || body || {})
|
|
56
|
+
sendJson(res, 200, await refreshPluginsAndAgentTools())
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const error = new Error('Not found')
|
|
61
|
+
error.statusCode = 404
|
|
62
|
+
throw error
|
|
63
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { streamSimple } from '@
|
|
1
|
+
import { streamSimple } from '@earendil-works/pi-ai'
|
|
2
2
|
import { readJsonBody, sendJson, decodeSegment } from '../utils/response.mjs'
|
|
3
3
|
import { readStore, atomicUpdate } from '../storage.mjs'
|
|
4
4
|
import { createAgent, getSessionEventBus, agentEvents, persistSessionState } from '../agent-manager.mjs'
|
|
@@ -1,8 +1,72 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
2
|
import { sendJson, readJsonBody, decodeSegment } from '../utils/response.mjs'
|
|
3
|
-
import { readStore, writeStore, atomicUpdate, getComparable, readSessionStoreScoped, readSessionValue, writeSessionValue, deleteSessionValue, ensureStorage, dataDir, configDir, storageDir, cacheDir, logsDir } from '../storage.mjs'
|
|
3
|
+
import { readStore, writeStore, atomicUpdate, getComparable, getStoreRevision, readSessionStoreScoped, readSessionValue, writeSessionValue, deleteSessionValue, ensureStorage, dataDir, configDir, storageDir, cacheDir, logsDir } from '../storage.mjs'
|
|
4
4
|
import { directorySize } from '../utils/workspace.mjs'
|
|
5
5
|
|
|
6
|
+
const metadataIndexCache = new Map()
|
|
7
|
+
const MAX_METADATA_INDEX_CACHE_ENTRIES = 50
|
|
8
|
+
|
|
9
|
+
function metadataIndexCacheKey({ scope, projectId, indexName, direction }) {
|
|
10
|
+
return JSON.stringify({ scope: scope || '', projectId: projectId || '', indexName, direction })
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function sortIndexedValues(values, store, indexName, direction) {
|
|
14
|
+
values.sort((a, b) => {
|
|
15
|
+
if (store === 'sessions-metadata' && indexName === 'lastModified') {
|
|
16
|
+
const leftPinned = getComparable(a, 'pinnedAt')
|
|
17
|
+
const rightPinned = getComparable(b, 'pinnedAt')
|
|
18
|
+
if (leftPinned !== rightPinned) {
|
|
19
|
+
if (leftPinned === undefined || leftPinned === null) return 1
|
|
20
|
+
if (rightPinned === undefined || rightPinned === null) return -1
|
|
21
|
+
return -String(leftPinned).localeCompare(String(rightPinned))
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const left = getComparable(a, indexName)
|
|
26
|
+
const right = getComparable(b, indexName)
|
|
27
|
+
if (left === right) return 0
|
|
28
|
+
if (left === undefined || left === null) return direction === 'desc' ? 1 : -1
|
|
29
|
+
if (right === undefined || right === null) return direction === 'desc' ? -1 : 1
|
|
30
|
+
const result = String(left).localeCompare(String(right))
|
|
31
|
+
return direction === 'desc' ? -result : result
|
|
32
|
+
})
|
|
33
|
+
return values
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function readIndexedValues(store, indexName, direction, scope, projectId) {
|
|
37
|
+
if (store !== 'sessions-metadata') {
|
|
38
|
+
let data
|
|
39
|
+
if (scope && store === 'sessions') {
|
|
40
|
+
data = await readSessionStoreScoped(store, scope, scope === 'project' ? projectId : undefined)
|
|
41
|
+
} else {
|
|
42
|
+
data = await readStore(store)
|
|
43
|
+
}
|
|
44
|
+
return sortIndexedValues(Object.values(data), store, indexName, direction)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const revision = getStoreRevision(store)
|
|
48
|
+
const key = metadataIndexCacheKey({ scope, projectId, indexName, direction })
|
|
49
|
+
const cached = metadataIndexCache.get(key)
|
|
50
|
+
if (cached && cached.revision === revision) return cached.values
|
|
51
|
+
|
|
52
|
+
const data = scope
|
|
53
|
+
? await readSessionStoreScoped(store, scope, scope === 'project' ? projectId : undefined)
|
|
54
|
+
: await readStore(store)
|
|
55
|
+
const values = sortIndexedValues(
|
|
56
|
+
Object.values(data).filter((value) => value?.messageCount !== 0),
|
|
57
|
+
store,
|
|
58
|
+
indexName,
|
|
59
|
+
direction,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
metadataIndexCache.set(key, { revision, values })
|
|
63
|
+
if (metadataIndexCache.size > MAX_METADATA_INDEX_CACHE_ENTRIES) {
|
|
64
|
+
const firstKey = metadataIndexCache.keys().next().value
|
|
65
|
+
if (firstKey) metadataIndexCache.delete(firstKey)
|
|
66
|
+
}
|
|
67
|
+
return values
|
|
68
|
+
}
|
|
69
|
+
|
|
6
70
|
export async function handleStorageApi(req, res, url) {
|
|
7
71
|
const parts = url.pathname.split('/').filter(Boolean)
|
|
8
72
|
|
|
@@ -44,36 +108,7 @@ export async function handleStorageApi(req, res, url) {
|
|
|
44
108
|
|
|
45
109
|
await ensureStorage()
|
|
46
110
|
|
|
47
|
-
|
|
48
|
-
if (scope && (store === 'sessions' || store === 'sessions-metadata')) {
|
|
49
|
-
data = await readSessionStoreScoped(store, scope, scope === 'project' ? projectId : undefined)
|
|
50
|
-
} else {
|
|
51
|
-
data = await readStore(store)
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
let values = Object.values(data)
|
|
55
|
-
if (store === 'sessions-metadata') {
|
|
56
|
-
values = values.filter((value) => value?.messageCount !== 0)
|
|
57
|
-
}
|
|
58
|
-
values.sort((a, b) => {
|
|
59
|
-
if (store === 'sessions-metadata' && indexName === 'lastModified') {
|
|
60
|
-
const leftPinned = getComparable(a, 'pinnedAt')
|
|
61
|
-
const rightPinned = getComparable(b, 'pinnedAt')
|
|
62
|
-
if (leftPinned !== rightPinned) {
|
|
63
|
-
if (leftPinned === undefined || leftPinned === null) return 1
|
|
64
|
-
if (rightPinned === undefined || rightPinned === null) return -1
|
|
65
|
-
return -String(leftPinned).localeCompare(String(rightPinned))
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const left = getComparable(a, indexName)
|
|
70
|
-
const right = getComparable(b, indexName)
|
|
71
|
-
if (left === right) return 0
|
|
72
|
-
if (left === undefined || left === null) return direction === 'desc' ? 1 : -1
|
|
73
|
-
if (right === undefined || right === null) return direction === 'desc' ? -1 : 1
|
|
74
|
-
const result = String(left).localeCompare(String(right))
|
|
75
|
-
return direction === 'desc' ? -result : result
|
|
76
|
-
})
|
|
111
|
+
const values = await readIndexedValues(store, indexName, direction, scope, projectId)
|
|
77
112
|
|
|
78
113
|
const total = values.length
|
|
79
114
|
const limit = limitParam ? parseInt(limitParam, 10) : undefined
|