@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.
Files changed (57) hide show
  1. package/README.md +24 -18
  2. package/dist/assets/anthropic-BcnDL7hi.js +39 -0
  3. package/dist/assets/azure-openai-responses-BEfdv0qd.js +1 -0
  4. package/dist/assets/google-C2y985rW.js +1 -0
  5. package/dist/assets/google-shared-Cqjw1plk.js +11 -0
  6. package/dist/assets/google-vertex-Jf9zNsCF.js +1 -0
  7. package/dist/assets/{icons-DmRYmmql.js → icons-BVM5--R9.js} +1 -1
  8. package/dist/assets/{index-s72bxhrh.js → index-8Q1Ovled.js} +604 -550
  9. package/dist/assets/index-ZYbEKGUp.css +3 -0
  10. package/dist/assets/{mistral-DCZ8VphX.js → mistral-qYbgRY3z.js} +1 -1
  11. package/dist/assets/openai-codex-responses--aAgyYJM.js +7 -0
  12. package/dist/assets/openai-completions-CHDluyXM.js +5 -0
  13. package/dist/assets/openai-prompt-cache-CErE62Yt.js +1 -0
  14. package/dist/assets/openai-responses-UtRriBXu.js +1 -0
  15. package/dist/assets/{openai-responses-shared-RzgnIlMf.js → openai-responses-shared-G6WDDqJ8.js} +1 -1
  16. package/dist/assets/openrouter-Dz9zwzUG.js +1 -0
  17. package/dist/assets/{react-vendor-BsV2HYbc.js → react-vendor-DAoL5p8_.js} +1 -1
  18. package/dist/assets/sanitize-unicode-BhyPmlyt.js +1 -0
  19. package/dist/assets/transform-messages-Dhj_4OTw.js +1 -0
  20. package/dist/index.html +4 -4
  21. package/package.json +4 -3
  22. package/server/agent-manager.mjs +162 -176
  23. package/server/ai-http-logger.mjs +20 -5
  24. package/server/approval-store.mjs +63 -0
  25. package/server/custom-commands.mjs +67 -9
  26. package/server/index.mjs +7 -0
  27. package/server/message-converters.mjs +79 -0
  28. package/server/plugins/loader.mjs +56 -0
  29. package/server/plugins/manifest.mjs +174 -0
  30. package/server/plugins/registry.mjs +304 -0
  31. package/server/project-config.mjs +53 -4
  32. package/server/routes/agent-profiles.mjs +1 -1
  33. package/server/routes/agent.mjs +1 -16
  34. package/server/routes/filesystem.mjs +18 -2
  35. package/server/routes/plugins.mjs +63 -0
  36. package/server/routes/project.mjs +2 -0
  37. package/server/routes/scheduled-tasks.mjs +1 -1
  38. package/server/routes/storage.mjs +66 -31
  39. package/server/routes/tools.mjs +12 -1
  40. package/server/session-utils.mjs +1 -1
  41. package/server/skills.mjs +64 -5
  42. package/server/storage.mjs +91 -8
  43. package/server/system-prompt.mjs +27 -5
  44. package/server/tool-wiring.mjs +113 -0
  45. package/server/utils/workspace.mjs +20 -1
  46. package/dist/assets/anthropic-BrbLtQkg.js +0 -39
  47. package/dist/assets/azure-openai-responses-q9QFpQk3.js +0 -1
  48. package/dist/assets/google-Bv6IeSRf.js +0 -1
  49. package/dist/assets/google-shared-CLc4ziON.js +0 -11
  50. package/dist/assets/google-vertex-Cwpe8vbn.js +0 -1
  51. package/dist/assets/index-C4m48ndP.css +0 -3
  52. package/dist/assets/openai-codex-responses-Bx7iyHzd.js +0 -7
  53. package/dist/assets/openai-completions-CihVV11E.js +0 -5
  54. package/dist/assets/openai-responses-BigEdUNS.js +0 -1
  55. package/dist/assets/transform-messages-CmnxG9RB.js +0 -1
  56. /package/dist/assets/{hash-Bt1aVMQ3.js → hash-kZ2KD_no.js} +0 -0
  57. /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 projectInstructions = null
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
- projectInstructions = await readInstructionsFile(path.join(context.workspaceRoot, 'AGENTS.md'))
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 globalInstructions = await readInstructionsFile(path.join(dataDir, 'AGENTS.md'))
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?.skills && project?.path
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 '@mariozechner/pi-ai'
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'
@@ -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
- sendJson(res, 200, await listFilesystemDirectories(url.searchParams.get('path')))
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
+ }
@@ -29,6 +29,8 @@ export async function handleProjectApi(req, res, url) {
29
29
  allowEdit: command.allowEdit,
30
30
  allowCommands: command.allowCommands,
31
31
  relativePath: command.relativePath,
32
+ source: command.source,
33
+ pluginName: command.pluginName,
32
34
  })),
33
35
  })
34
36
  return
@@ -1,4 +1,4 @@
1
- import { streamSimple } from '@mariozechner/pi-ai'
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
- let data
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