@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.
Files changed (45) hide show
  1. package/README.md +14 -14
  2. package/dist/assets/anthropic-B1_Yrokl.js +39 -0
  3. package/dist/assets/azure-openai-responses-UMiOBCBd.js +1 -0
  4. package/dist/assets/google-BLE_Gcd1.js +1 -0
  5. package/dist/assets/google-shared-Cqjw1plk.js +11 -0
  6. package/dist/assets/google-vertex-6_sIZLVc.js +1 -0
  7. package/dist/assets/{icons-DmRYmmql.js → icons-Bs7OG8yi.js} +1 -1
  8. package/dist/assets/{index-s72bxhrh.js → index-C3bc5C3k.js} +550 -544
  9. package/dist/assets/index-C7oT9Rdw.css +3 -0
  10. package/dist/assets/{mistral-DCZ8VphX.js → mistral-DmZEmRkv.js} +1 -1
  11. package/dist/assets/openai-codex-responses-i_SmQGzQ.js +7 -0
  12. package/dist/assets/openai-completions-BmmZFDDY.js +5 -0
  13. package/dist/assets/openai-prompt-cache-CErE62Yt.js +1 -0
  14. package/dist/assets/openai-responses-C8tPdeE9.js +1 -0
  15. package/dist/assets/{openai-responses-shared-RzgnIlMf.js → openai-responses-shared-DchtjQNp.js} +1 -1
  16. package/dist/assets/openrouter-CcTv1G_v.js +1 -0
  17. package/dist/assets/{react-vendor-BsV2HYbc.js → react-vendor-Cu-7p9CI.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 +3 -3
  22. package/server/agent-manager.mjs +66 -160
  23. package/server/ai-http-logger.mjs +20 -5
  24. package/server/approval-store.mjs +63 -0
  25. package/server/message-converters.mjs +79 -0
  26. package/server/routes/agent-profiles.mjs +1 -1
  27. package/server/routes/filesystem.mjs +18 -2
  28. package/server/routes/scheduled-tasks.mjs +1 -1
  29. package/server/routes/storage.mjs +66 -31
  30. package/server/session-utils.mjs +1 -1
  31. package/server/storage.mjs +78 -2
  32. package/server/tool-wiring.mjs +87 -0
  33. package/server/utils/workspace.mjs +20 -1
  34. package/dist/assets/anthropic-BrbLtQkg.js +0 -39
  35. package/dist/assets/azure-openai-responses-q9QFpQk3.js +0 -1
  36. package/dist/assets/google-Bv6IeSRf.js +0 -1
  37. package/dist/assets/google-shared-CLc4ziON.js +0 -11
  38. package/dist/assets/google-vertex-Cwpe8vbn.js +0 -1
  39. package/dist/assets/index-C4m48ndP.css +0 -3
  40. package/dist/assets/openai-codex-responses-Bx7iyHzd.js +0 -7
  41. package/dist/assets/openai-completions-CihVV11E.js +0 -5
  42. package/dist/assets/openai-responses-BigEdUNS.js +0 -1
  43. package/dist/assets/transform-messages-CmnxG9RB.js +0 -1
  44. /package/dist/assets/{hash-Bt1aVMQ3.js → hash-kZ2KD_no.js} +0 -0
  45. /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 '@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'
@@ -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
 
@@ -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
@@ -1,4 +1,4 @@
1
- import { streamSimple } from '@mariozechner/pi-ai'
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'
@@ -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 [sessionId, meta] of Object.entries(data || {})) {
510
- if (meta && typeof meta === 'object') sessionBucketIndex.set(sessionId, sessionBucket(meta))
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
- return sizes.reduce((sum, value) => sum + value, 0)
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
  }