@shawnstack/quickforge 1.3.24 → 1.3.25
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 +14 -14
- package/dist/assets/anthropic-B1_Yrokl.js +39 -0
- package/dist/assets/azure-openai-responses-UMiOBCBd.js +1 -0
- package/dist/assets/google-BLE_Gcd1.js +1 -0
- package/dist/assets/google-shared-Cqjw1plk.js +11 -0
- package/dist/assets/google-vertex-6_sIZLVc.js +1 -0
- package/dist/assets/{icons-DmRYmmql.js → icons-Bs7OG8yi.js} +1 -1
- package/dist/assets/{index-s72bxhrh.js → index-C3bc5C3k.js} +550 -544
- package/dist/assets/index-C7oT9Rdw.css +3 -0
- package/dist/assets/{mistral-DCZ8VphX.js → mistral-DmZEmRkv.js} +1 -1
- package/dist/assets/openai-codex-responses-i_SmQGzQ.js +7 -0
- package/dist/assets/openai-completions-BmmZFDDY.js +5 -0
- package/dist/assets/openai-prompt-cache-CErE62Yt.js +1 -0
- package/dist/assets/openai-responses-C8tPdeE9.js +1 -0
- package/dist/assets/{openai-responses-shared-RzgnIlMf.js → openai-responses-shared-DchtjQNp.js} +1 -1
- package/dist/assets/openrouter-CcTv1G_v.js +1 -0
- package/dist/assets/{react-vendor-BsV2HYbc.js → react-vendor-Cu-7p9CI.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 +3 -3
- package/server/agent-manager.mjs +66 -160
- package/server/ai-http-logger.mjs +20 -5
- package/server/approval-store.mjs +63 -0
- package/server/message-converters.mjs +79 -0
- package/server/routes/agent-profiles.mjs +1 -1
- package/server/routes/filesystem.mjs +18 -2
- package/server/routes/scheduled-tasks.mjs +1 -1
- package/server/routes/storage.mjs +66 -31
- package/server/session-utils.mjs +1 -1
- package/server/storage.mjs +78 -2
- package/server/tool-wiring.mjs +87 -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,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LLM message format converters.
|
|
3
|
+
*
|
|
4
|
+
* Pure functions that transform AgentMessage[] to LLM-compatible Message[]
|
|
5
|
+
* and extract text content from messages. No module-level state.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Strip the `details` property from a message object.
|
|
10
|
+
* Returns a shallow copy so the original message is not mutated.
|
|
11
|
+
*/
|
|
12
|
+
export function omitDetailsForLlm(message) {
|
|
13
|
+
if (!message || typeof message !== 'object' || message.details === undefined) return message
|
|
14
|
+
const copy = { ...message }
|
|
15
|
+
delete copy.details
|
|
16
|
+
return copy
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Convert AgentMessage[] to LLM-compatible Message[].
|
|
21
|
+
* Handles "user-with-attachments" → "user" with multi-modal content blocks.
|
|
22
|
+
* Without this the default pi-agent-core convertToLlm silently drops
|
|
23
|
+
* user-with-attachments messages, so the LLM never sees attachments.
|
|
24
|
+
*/
|
|
25
|
+
export function serverConvertToLlm(messages) {
|
|
26
|
+
return messages
|
|
27
|
+
.filter(m => m.role !== 'artifact')
|
|
28
|
+
.map(m => {
|
|
29
|
+
if (m.role === 'user-with-attachments') {
|
|
30
|
+
const textContent = typeof m.content === 'string'
|
|
31
|
+
? [{ type: 'text', text: m.content }]
|
|
32
|
+
: [...m.content]
|
|
33
|
+
if (Array.isArray(m.attachments)) {
|
|
34
|
+
for (const att of m.attachments) {
|
|
35
|
+
if (att.type === 'image' && att.content) {
|
|
36
|
+
textContent.push({ type: 'image', data: att.content, mimeType: att.mimeType })
|
|
37
|
+
} else if (att.type === 'document' && att.extractedText) {
|
|
38
|
+
textContent.push({ type: 'text', text: `\n\n[Document: ${att.fileName}]\n${att.extractedText}` })
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return omitDetailsForLlm({ ...m, role: 'user', content: textContent })
|
|
43
|
+
}
|
|
44
|
+
if (m.role === 'user' || m.role === 'assistant' || m.role === 'toolResult') return omitDetailsForLlm(m)
|
|
45
|
+
return null
|
|
46
|
+
})
|
|
47
|
+
.filter(Boolean)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Extract plain text content from a message object.
|
|
52
|
+
* Handles string content and ContentBlock[] arrays.
|
|
53
|
+
*/
|
|
54
|
+
export function messageText(message) {
|
|
55
|
+
const content = message?.content
|
|
56
|
+
if (typeof content === 'string') return content
|
|
57
|
+
if (Array.isArray(content)) {
|
|
58
|
+
return content
|
|
59
|
+
.filter((block) => block?.type === 'text')
|
|
60
|
+
.map((block) => block.text ?? '')
|
|
61
|
+
.join('\n')
|
|
62
|
+
.trim()
|
|
63
|
+
}
|
|
64
|
+
return ''
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Find the last assistant message with non-empty text content.
|
|
69
|
+
* Returns the text string, or '' if no assistant text is found.
|
|
70
|
+
*/
|
|
71
|
+
export function lastAssistantText(messages) {
|
|
72
|
+
for (let index = messages.length - 1; index >= 0; index--) {
|
|
73
|
+
const message = messages[index]
|
|
74
|
+
if (message?.role !== 'assistant') continue
|
|
75
|
+
const text = messageText(message)
|
|
76
|
+
if (text) return text
|
|
77
|
+
}
|
|
78
|
+
return ''
|
|
79
|
+
}
|
|
@@ -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'
|
|
@@ -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
|
|
|
@@ -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
|
package/server/session-utils.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { streamSimple } from '@
|
|
1
|
+
import { streamSimple } from '@earendil-works/pi-ai'
|
|
2
2
|
import { buildInstructionsPayload } from './project-config.mjs'
|
|
3
3
|
import { composeSystemPrompt } from './system-prompt.mjs'
|
|
4
4
|
import { listSubagentProfiles } from './agent-profiles.mjs'
|
package/server/storage.mjs
CHANGED
|
@@ -62,6 +62,17 @@ export const stores = new Set([
|
|
|
62
62
|
const sessionBucketIndex = new Map()
|
|
63
63
|
let bucketIndexBuilt = false
|
|
64
64
|
|
|
65
|
+
// Monotonic in-process revisions for cache invalidation in route-level indexes.
|
|
66
|
+
const storeRevisions = new Map()
|
|
67
|
+
|
|
68
|
+
function bumpStoreRevision(storeName) {
|
|
69
|
+
storeRevisions.set(storeName, (storeRevisions.get(storeName) || 0) + 1)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function getStoreRevision(storeName) {
|
|
73
|
+
return storeRevisions.get(storeName) || 0
|
|
74
|
+
}
|
|
75
|
+
|
|
65
76
|
const configStores = new Set(['settings', 'provider-keys', 'custom-providers'])
|
|
66
77
|
const sessionStores = new Set(['sessions', 'sessions-metadata'])
|
|
67
78
|
|
|
@@ -370,6 +381,34 @@ async function readAllSessionValues() {
|
|
|
370
381
|
return result
|
|
371
382
|
}
|
|
372
383
|
|
|
384
|
+
function sessionMetadataQueueName(bucket) {
|
|
385
|
+
return bucket.scope === 'project' ? `sessions-metadata:${bucket.projectId}` : 'sessions-metadata:global'
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function sameSessionBucket(left, right) {
|
|
389
|
+
if (!left || !right) return false
|
|
390
|
+
return left.scope === right.scope && (left.projectId || undefined) === (right.projectId || undefined)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function updateSessionMetadataBucketIndex(bucket, previousData, nextData) {
|
|
394
|
+
const ids = new Set([
|
|
395
|
+
...Object.keys(previousData || {}),
|
|
396
|
+
...Object.keys(nextData || {}),
|
|
397
|
+
])
|
|
398
|
+
|
|
399
|
+
for (const sessionId of ids) {
|
|
400
|
+
const meta = nextData?.[sessionId]
|
|
401
|
+
if (meta && typeof meta === 'object') {
|
|
402
|
+
sessionBucketIndex.set(sessionId, sessionBucket(meta))
|
|
403
|
+
continue
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (sameSessionBucket(sessionBucketIndex.get(sessionId), bucket)) {
|
|
407
|
+
sessionBucketIndex.delete(sessionId)
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
373
412
|
async function writeSessionValueFile(sessionId, value) {
|
|
374
413
|
await writeJsonAtomic(sessionDataFile(sessionId, sessionBucket(value)), value)
|
|
375
414
|
// Keep in-memory index current
|
|
@@ -497,6 +536,15 @@ async function writeSessionStore(storeName, data) {
|
|
|
497
536
|
filesToWrite.add(sessionStoreFile(storeName, bucket))
|
|
498
537
|
}
|
|
499
538
|
|
|
539
|
+
const previousByFile = new Map()
|
|
540
|
+
if (storeName === 'sessions-metadata') {
|
|
541
|
+
await Promise.all(
|
|
542
|
+
[...filesToWrite].map(async (file) => {
|
|
543
|
+
previousByFile.set(file, await readJsonFile(file, {}))
|
|
544
|
+
}),
|
|
545
|
+
)
|
|
546
|
+
}
|
|
547
|
+
|
|
500
548
|
await Promise.all(
|
|
501
549
|
[...filesToWrite].map(async (file) => {
|
|
502
550
|
const bucketEntry = [...buckets.values()].find((entry) => sessionStoreFile(storeName, entry.bucket) === file)
|
|
@@ -506,9 +554,14 @@ async function writeSessionStore(storeName, data) {
|
|
|
506
554
|
|
|
507
555
|
// Keep in-memory bucket index current for metadata writes
|
|
508
556
|
if (storeName === 'sessions-metadata') {
|
|
509
|
-
for (const
|
|
510
|
-
|
|
557
|
+
for (const file of filesToWrite) {
|
|
558
|
+
const bucketEntry = [...buckets.values()].find((entry) => sessionStoreFile(storeName, entry.bucket) === file)
|
|
559
|
+
const bucket = bucketEntry?.bucket ?? (file === sessionStoreFile(storeName, { scope: 'global' })
|
|
560
|
+
? { scope: 'global' }
|
|
561
|
+
: { scope: 'project', projectId: path.basename(path.dirname(file)) })
|
|
562
|
+
updateSessionMetadataBucketIndex(bucket, previousByFile.get(file) ?? {}, bucketEntry?.data ?? {})
|
|
511
563
|
}
|
|
564
|
+
bumpStoreRevision(storeName)
|
|
512
565
|
}
|
|
513
566
|
}
|
|
514
567
|
|
|
@@ -607,6 +660,29 @@ export async function atomicUpdate(storeName, updateFn) {
|
|
|
607
660
|
})
|
|
608
661
|
}
|
|
609
662
|
|
|
663
|
+
/**
|
|
664
|
+
* Atomically read-modify-write the scoped sessions metadata file within its serialized write queue.
|
|
665
|
+
*
|
|
666
|
+
* @param {string} scope
|
|
667
|
+
* @param {string|null|undefined} projectId
|
|
668
|
+
* @param {(data: object) => object} updateFn — receives current scoped metadata, returns updated metadata
|
|
669
|
+
* @returns {Promise<object>} the updated scoped metadata
|
|
670
|
+
*/
|
|
671
|
+
export async function atomicSessionMetadataUpdate(scope, projectId, updateFn) {
|
|
672
|
+
const bucket = scope === 'project' ? { scope: 'project', projectId } : { scope: 'global' }
|
|
673
|
+
const file = sessionStoreFile('sessions-metadata', bucket)
|
|
674
|
+
return enqueueWrite(sessionMetadataQueueName(bucket), async () => {
|
|
675
|
+
await ensureStorage()
|
|
676
|
+
const data = await readJsonFile(file, {})
|
|
677
|
+
const previousData = { ...data }
|
|
678
|
+
const updated = updateFn(data)
|
|
679
|
+
await writeJsonAtomic(file, updated)
|
|
680
|
+
updateSessionMetadataBucketIndex(bucket, previousData, updated)
|
|
681
|
+
bumpStoreRevision('sessions-metadata')
|
|
682
|
+
return updated
|
|
683
|
+
})
|
|
684
|
+
}
|
|
685
|
+
|
|
610
686
|
/**
|
|
611
687
|
* Atomically read-modify-write the project config within the config queue.
|
|
612
688
|
*/
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool definition wrapping and execution wiring.
|
|
3
|
+
*
|
|
4
|
+
* Wraps raw tool definitions with execution handlers that inject
|
|
5
|
+
* timing metadata, permission checks, and context.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { toolHandlers } from './tools/index.mjs'
|
|
9
|
+
import { callMcpTool } from './mcp/registry.mjs'
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Helpers
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
export function isPlainObject(value) {
|
|
16
|
+
return Boolean(value && typeof value === 'object' && !Array.isArray(value))
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function mergeQuickForgeTiming(details, timing) {
|
|
20
|
+
if (!isPlainObject(details)) return { quickforgeTiming: timing }
|
|
21
|
+
return { ...details, quickforgeTiming: timing }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Tool wrappers
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
export function wrapToolDefinition(definition, context, toolPermissions) {
|
|
29
|
+
const handler = toolHandlers[definition.name]
|
|
30
|
+
if (!handler) throw new Error(`Missing handler for tool: ${definition.name}`)
|
|
31
|
+
return {
|
|
32
|
+
...definition,
|
|
33
|
+
execute: async (_toolCallId, params, signal, onUpdate) => {
|
|
34
|
+
if (toolPermissions) {
|
|
35
|
+
const permissionError = toolPermissions(definition.name)
|
|
36
|
+
if (permissionError) throw new Error(permissionError)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const startedAt = Date.now()
|
|
40
|
+
const startedAtPerf = performance.now()
|
|
41
|
+
const result = await handler(params || {}, context, { signal, onUpdate, toolCallId: _toolCallId })
|
|
42
|
+
const finishedAt = Date.now()
|
|
43
|
+
const durationMs = Math.max(0, Math.round(performance.now() - startedAtPerf))
|
|
44
|
+
const details = mergeQuickForgeTiming(result.details, { startedAt, finishedAt, durationMs })
|
|
45
|
+
return {
|
|
46
|
+
content: [{ type: 'text', text: result.content }],
|
|
47
|
+
details: isPlainObject(details) ? { ...details, toolCallId: _toolCallId } : details,
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function wrapMcpToolDefinition(definition, toolPermissions) {
|
|
54
|
+
return {
|
|
55
|
+
...definition,
|
|
56
|
+
execute: async (_toolCallId, params) => {
|
|
57
|
+
if (toolPermissions) {
|
|
58
|
+
const permissionError = toolPermissions(definition.name)
|
|
59
|
+
if (permissionError) throw new Error(permissionError)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const startedAt = Date.now()
|
|
63
|
+
const startedAtPerf = performance.now()
|
|
64
|
+
const result = await callMcpTool(definition.name, params || {})
|
|
65
|
+
const finishedAt = Date.now()
|
|
66
|
+
const durationMs = Math.max(0, Math.round(performance.now() - startedAtPerf))
|
|
67
|
+
if (result.isError) {
|
|
68
|
+
throw new Error(result.content || `MCP tool failed: ${definition.name}`)
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
content: [{ type: 'text', text: result.content }],
|
|
72
|
+
details: mergeQuickForgeTiming(result.details, { startedAt, finishedAt, durationMs }),
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// Skill context
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
export function sessionSkillsContext(session) {
|
|
83
|
+
return {
|
|
84
|
+
globalSkillNames: session.globalSkillNames,
|
|
85
|
+
projectSkillNames: session.projectSkillNames,
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -131,21 +131,40 @@ export async function assertDirectory(dir) {
|
|
|
131
131
|
}
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
const SIZE_SKIP_DIRS = new Set(['.git', 'node_modules', 'dist', 'dist-ssr', '.vite', '.cache', '.next', '.nuxt', '__pycache__', '.venv', 'venv'])
|
|
135
|
+
const directorySizeCache = new Map()
|
|
136
|
+
const DIRECTORY_SIZE_CACHE_TTL_MS = 10_000
|
|
137
|
+
|
|
134
138
|
export async function directorySize(dir) {
|
|
135
139
|
try {
|
|
140
|
+
const now = Date.now()
|
|
141
|
+
const cached = directorySizeCache.get(dir)
|
|
142
|
+
if (cached && now - cached.ts < DIRECTORY_SIZE_CACHE_TTL_MS) return cached.size
|
|
143
|
+
|
|
136
144
|
const entries = await fs.readdir(dir, { withFileTypes: true })
|
|
137
145
|
const sizes = await Promise.all(entries.map(async (entry) => {
|
|
146
|
+
if (entry.isDirectory() && SIZE_SKIP_DIRS.has(entry.name)) return 0
|
|
138
147
|
const full = path.join(dir, entry.name)
|
|
139
148
|
if (entry.isDirectory()) return directorySize(full)
|
|
140
149
|
const stat = await fs.stat(full)
|
|
141
150
|
return stat.size
|
|
142
151
|
}))
|
|
143
|
-
|
|
152
|
+
const size = sizes.reduce((sum, value) => sum + value, 0)
|
|
153
|
+
directorySizeCache.set(dir, { size, ts: now })
|
|
154
|
+
return size
|
|
144
155
|
} catch {
|
|
145
156
|
return 0
|
|
146
157
|
}
|
|
147
158
|
}
|
|
148
159
|
|
|
160
|
+
export function invalidateDirectorySizeCache(dir) {
|
|
161
|
+
if (dir) {
|
|
162
|
+
directorySizeCache.delete(dir)
|
|
163
|
+
} else {
|
|
164
|
+
directorySizeCache.clear()
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
149
168
|
export function shouldSkipSearchDir(name) {
|
|
150
169
|
return ['.git', 'node_modules', 'dist', 'dist-ssr', '.vite'].includes(name)
|
|
151
170
|
}
|