@swarmclawai/swarmclaw 0.6.0 → 0.6.3
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 +56 -42
- package/bin/server-cmd.js +1 -0
- package/package.json +2 -1
- package/src/app/api/canvas/[sessionId]/route.ts +31 -0
- package/src/app/api/chatrooms/[id]/chat/route.ts +10 -136
- package/src/app/api/connectors/[id]/route.ts +1 -0
- package/src/app/api/connectors/route.ts +2 -1
- package/src/app/api/files/open/route.ts +43 -0
- package/src/app/api/search/route.ts +9 -7
- package/src/app/api/sessions/[id]/messages/route.ts +70 -2
- package/src/app/api/sessions/[id]/route.ts +4 -0
- package/src/app/api/tasks/metrics/route.ts +101 -0
- package/src/app/api/tasks/route.ts +17 -2
- package/src/app/api/tts/route.ts +16 -35
- package/src/app/api/tts/stream/route.ts +14 -42
- package/src/app/api/uploads/[filename]/route.ts +19 -34
- package/src/app/api/uploads/route.ts +94 -0
- package/src/app/globals.css +5 -0
- package/src/cli/index.js +16 -1
- package/src/cli/spec.js +26 -0
- package/src/components/agents/agent-card.tsx +3 -3
- package/src/components/agents/agent-chat-list.tsx +29 -6
- package/src/components/agents/agent-sheet.tsx +66 -4
- package/src/components/agents/inspector-panel.tsx +81 -6
- package/src/components/agents/openclaw-skills-panel.tsx +32 -3
- package/src/components/agents/personality-builder.tsx +42 -14
- package/src/components/agents/soul-library-picker.tsx +89 -0
- package/src/components/canvas/canvas-panel.tsx +96 -0
- package/src/components/chat/activity-moment.tsx +8 -4
- package/src/components/chat/chat-area.tsx +76 -24
- package/src/components/chat/chat-header.tsx +522 -286
- package/src/components/chat/chat-preview-panel.tsx +1 -2
- package/src/components/chat/delegation-banner.tsx +371 -0
- package/src/components/chat/file-path-chip.tsx +23 -2
- package/src/components/chat/heartbeat-history-panel.tsx +269 -0
- package/src/components/chat/message-bubble.tsx +315 -25
- package/src/components/chat/message-list.tsx +113 -8
- package/src/components/chat/streaming-bubble.tsx +68 -1
- package/src/components/chat/tool-call-bubble.tsx +45 -3
- package/src/components/chat/transfer-agent-picker.tsx +1 -1
- package/src/components/chatrooms/chatroom-list.tsx +8 -1
- package/src/components/chatrooms/chatroom-message.tsx +8 -3
- package/src/components/chatrooms/chatroom-view.tsx +3 -3
- package/src/components/connectors/connector-list.tsx +168 -90
- package/src/components/connectors/connector-sheet.tsx +84 -17
- package/src/components/home/home-view.tsx +1 -1
- package/src/components/input/chat-input.tsx +28 -2
- package/src/components/layout/app-layout.tsx +19 -2
- package/src/components/projects/project-detail.tsx +1 -1
- package/src/components/schedules/schedule-sheet.tsx +260 -127
- package/src/components/settings/gateway-disconnect-overlay.tsx +80 -0
- package/src/components/shared/agent-switch-dialog.tsx +1 -1
- package/src/components/shared/chatroom-picker-list.tsx +61 -0
- package/src/components/shared/connector-platform-icon.tsx +51 -4
- package/src/components/shared/icon-button.tsx +16 -2
- package/src/components/shared/keyboard-shortcuts-dialog.tsx +1 -1
- package/src/components/shared/search-dialog.tsx +17 -10
- package/src/components/shared/settings/section-embedding.tsx +48 -13
- package/src/components/shared/settings/section-orchestrator.tsx +46 -15
- package/src/components/shared/settings/section-storage.tsx +206 -0
- package/src/components/shared/settings/section-user-preferences.tsx +18 -0
- package/src/components/shared/settings/section-voice.tsx +42 -21
- package/src/components/shared/settings/section-web-search.tsx +30 -6
- package/src/components/shared/settings/settings-page.tsx +3 -1
- package/src/components/shared/settings/storage-browser.tsx +259 -0
- package/src/components/tasks/task-card.tsx +14 -1
- package/src/components/tasks/task-sheet.tsx +328 -3
- package/src/components/usage/metrics-dashboard.tsx +90 -6
- package/src/hooks/use-continuous-speech.ts +10 -4
- package/src/hooks/use-voice-conversation.ts +53 -10
- package/src/hooks/use-ws.ts +4 -2
- package/src/lib/providers/anthropic.ts +13 -7
- package/src/lib/providers/index.ts +1 -0
- package/src/lib/providers/openai.ts +13 -7
- package/src/lib/server/chat-execution.ts +125 -14
- package/src/lib/server/chatroom-helpers.ts +146 -0
- package/src/lib/server/connectors/connector-routing.test.ts +118 -1
- package/src/lib/server/connectors/discord.ts +31 -8
- package/src/lib/server/connectors/manager.ts +594 -16
- package/src/lib/server/connectors/media.ts +5 -0
- package/src/lib/server/connectors/telegram.ts +12 -2
- package/src/lib/server/connectors/types.ts +2 -0
- package/src/lib/server/connectors/whatsapp.ts +28 -2
- package/src/lib/server/elevenlabs.test.ts +60 -0
- package/src/lib/server/elevenlabs.ts +103 -0
- package/src/lib/server/heartbeat-service.ts +8 -1
- package/src/lib/server/main-agent-loop.ts +1 -1
- package/src/lib/server/memory-consolidation.ts +15 -2
- package/src/lib/server/memory-db.ts +134 -6
- package/src/lib/server/mime.ts +51 -0
- package/src/lib/server/openclaw-gateway.ts +2 -2
- package/src/lib/server/orchestrator-lg.ts +2 -0
- package/src/lib/server/orchestrator.ts +5 -2
- package/src/lib/server/playwright-proxy.mjs +2 -3
- package/src/lib/server/prompt-runtime-context.ts +53 -0
- package/src/lib/server/queue.ts +182 -8
- package/src/lib/server/session-tools/canvas.ts +67 -0
- package/src/lib/server/session-tools/connector.ts +583 -63
- package/src/lib/server/session-tools/crud.ts +21 -0
- package/src/lib/server/session-tools/delegate.ts +68 -4
- package/src/lib/server/session-tools/file.ts +26 -7
- package/src/lib/server/session-tools/git.ts +71 -0
- package/src/lib/server/session-tools/http.ts +57 -0
- package/src/lib/server/session-tools/index.ts +8 -0
- package/src/lib/server/session-tools/memory.ts +1 -0
- package/src/lib/server/session-tools/search-providers.ts +16 -8
- package/src/lib/server/session-tools/subagent.ts +106 -0
- package/src/lib/server/session-tools/web.ts +118 -8
- package/src/lib/server/stream-agent-chat.ts +39 -10
- package/src/lib/server/task-mention.ts +41 -0
- package/src/lib/sessions.ts +10 -0
- package/src/lib/soul-library.ts +103 -0
- package/src/lib/task-dedupe.ts +26 -0
- package/src/lib/tool-definitions.ts +2 -0
- package/src/lib/tts.ts +2 -2
- package/src/stores/use-app-store.ts +5 -1
- package/src/stores/use-chat-store.ts +65 -2
- package/src/types/index.ts +32 -2
|
@@ -74,6 +74,11 @@ export function isImageMime(mime: string): boolean {
|
|
|
74
74
|
return mime.startsWith('image/')
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/** Check if a MIME type is audio */
|
|
78
|
+
export function isAudioMime(mime: string): boolean {
|
|
79
|
+
return mime.startsWith('audio/')
|
|
80
|
+
}
|
|
81
|
+
|
|
77
82
|
export function inferInboundMediaType(mimeType?: string, fileName?: string, fallback: InboundMediaType = 'file'): InboundMediaType {
|
|
78
83
|
const probe = `${mimeType || ''} ${fileName || ''}`.toLowerCase()
|
|
79
84
|
if (probe.includes('image')) return 'image'
|
|
@@ -3,7 +3,7 @@ import fs from 'fs'
|
|
|
3
3
|
import path from 'path'
|
|
4
4
|
import type { Connector } from '@/types'
|
|
5
5
|
import type { PlatformConnector, ConnectorInstance, InboundMessage, InboundMediaType } from './types'
|
|
6
|
-
import { downloadInboundMediaToUpload, inferInboundMediaType, mimeFromPath, isImageMime } from './media'
|
|
6
|
+
import { downloadInboundMediaToUpload, inferInboundMediaType, mimeFromPath, isImageMime, isAudioMime } from './media'
|
|
7
7
|
import { isNoMessage } from './manager'
|
|
8
8
|
|
|
9
9
|
const telegram: PlatformConnector = {
|
|
@@ -181,6 +181,11 @@ const telegram: PlatformConnector = {
|
|
|
181
181
|
if (isImageMime(mime)) {
|
|
182
182
|
const msg = await bot.api.sendPhoto(chatId, inputFile, { caption })
|
|
183
183
|
return { messageId: String(msg.message_id) }
|
|
184
|
+
} else if (isAudioMime(mime)) {
|
|
185
|
+
const msg = options?.ptt
|
|
186
|
+
? await bot.api.sendVoice(chatId, inputFile, { caption })
|
|
187
|
+
: await bot.api.sendAudio(chatId, inputFile, { caption })
|
|
188
|
+
return { messageId: String(msg.message_id) }
|
|
184
189
|
} else {
|
|
185
190
|
const msg = await bot.api.sendDocument(chatId, inputFile, { caption })
|
|
186
191
|
return { messageId: String(msg.message_id) }
|
|
@@ -193,7 +198,12 @@ const telegram: PlatformConnector = {
|
|
|
193
198
|
}
|
|
194
199
|
// URL-based file
|
|
195
200
|
if (options?.fileUrl) {
|
|
196
|
-
const
|
|
201
|
+
const mime = options.mimeType || ''
|
|
202
|
+
const msg = isAudioMime(mime)
|
|
203
|
+
? options?.ptt
|
|
204
|
+
? await bot.api.sendVoice(chatId, options.fileUrl, { caption })
|
|
205
|
+
: await bot.api.sendAudio(chatId, options.fileUrl, { caption })
|
|
206
|
+
: await bot.api.sendDocument(chatId, options.fileUrl, { caption })
|
|
197
207
|
return { messageId: String(msg.message_id) }
|
|
198
208
|
}
|
|
199
209
|
// Text only
|
|
@@ -44,6 +44,8 @@ export interface ConnectorInstance {
|
|
|
44
44
|
mimeType?: string
|
|
45
45
|
fileName?: string
|
|
46
46
|
caption?: string
|
|
47
|
+
/** Send audio as a WhatsApp voice note (push-to-talk) */
|
|
48
|
+
ptt?: boolean
|
|
47
49
|
},
|
|
48
50
|
) => Promise<{ messageId?: string } | void>
|
|
49
51
|
/** Current QR code data URL (WhatsApp only, null when paired) */
|
|
@@ -10,12 +10,13 @@ import path from 'path'
|
|
|
10
10
|
import fs from 'fs'
|
|
11
11
|
import type { Connector } from '@/types'
|
|
12
12
|
import type { PlatformConnector, ConnectorInstance, InboundMessage } from './types'
|
|
13
|
-
import { saveInboundMediaBuffer, mimeFromPath, isImageMime } from './media'
|
|
13
|
+
import { saveInboundMediaBuffer, mimeFromPath, isImageMime, isAudioMime } from './media'
|
|
14
14
|
import { isNoMessage } from './manager'
|
|
15
15
|
|
|
16
16
|
import { DATA_DIR } from '../data-dir'
|
|
17
17
|
|
|
18
18
|
const AUTH_DIR = path.join(DATA_DIR, 'whatsapp-auth')
|
|
19
|
+
const INBOUND_DEDUPE_TTL_MS = 2 * 60 * 1000
|
|
19
20
|
|
|
20
21
|
/** Normalize a phone number for JID matching — strip leading 0 or + */
|
|
21
22
|
function normalizeNumber(num: string): string {
|
|
@@ -57,6 +58,7 @@ const whatsapp: PlatformConnector = {
|
|
|
57
58
|
let sock: ReturnType<typeof makeWASocket> | null = null
|
|
58
59
|
let stopped = false
|
|
59
60
|
let socketGen = 0 // Track socket generation to ignore stale events
|
|
61
|
+
const seenInboundMessageIds = new Map<string, number>()
|
|
60
62
|
|
|
61
63
|
const instance: ConnectorInstance = {
|
|
62
64
|
connector,
|
|
@@ -74,7 +76,15 @@ const whatsapp: PlatformConnector = {
|
|
|
74
76
|
const fName = options.fileName || path.basename(options.mediaPath)
|
|
75
77
|
let sent
|
|
76
78
|
if (isImageMime(mime)) {
|
|
77
|
-
|
|
79
|
+
try {
|
|
80
|
+
sent = await sock.sendMessage(channelId, { image: buf, caption, mimetype: mime })
|
|
81
|
+
} catch (err: unknown) {
|
|
82
|
+
const errMsg = err instanceof Error ? err.message : String(err)
|
|
83
|
+
console.warn(`[whatsapp] Image send failed (${errMsg}); retrying as document: ${fName}`)
|
|
84
|
+
sent = await sock.sendMessage(channelId, { document: buf, fileName: fName, mimetype: mime, caption })
|
|
85
|
+
}
|
|
86
|
+
} else if (isAudioMime(mime)) {
|
|
87
|
+
sent = await sock.sendMessage(channelId, { audio: buf, mimetype: mime, ptt: options.ptt !== false })
|
|
78
88
|
} else {
|
|
79
89
|
sent = await sock.sendMessage(channelId, { document: buf, fileName: fName, mimetype: mime, caption })
|
|
80
90
|
}
|
|
@@ -228,6 +238,22 @@ const whatsapp: PlatformConnector = {
|
|
|
228
238
|
|
|
229
239
|
if (msg.key.remoteJid === 'status@broadcast') continue
|
|
230
240
|
|
|
241
|
+
const msgId = msg.key.id || ''
|
|
242
|
+
if (msgId) {
|
|
243
|
+
const now = Date.now()
|
|
244
|
+
const seenAt = seenInboundMessageIds.get(msgId)
|
|
245
|
+
if (typeof seenAt === 'number' && now - seenAt <= INBOUND_DEDUPE_TTL_MS) {
|
|
246
|
+
console.log(`[whatsapp] Skipping duplicate inbound message id: ${msgId}`)
|
|
247
|
+
continue
|
|
248
|
+
}
|
|
249
|
+
seenInboundMessageIds.set(msgId, now)
|
|
250
|
+
if (seenInboundMessageIds.size > 5000) {
|
|
251
|
+
for (const [id, ts] of seenInboundMessageIds.entries()) {
|
|
252
|
+
if (now - ts > INBOUND_DEDUPE_TTL_MS) seenInboundMessageIds.delete(id)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
231
257
|
// Skip messages sent by the bot itself (tracked by ID to prevent infinite loops)
|
|
232
258
|
if (msg.key.id && sentMessageIds.has(msg.key.id)) {
|
|
233
259
|
console.log(`[whatsapp] Skipping own bot reply: ${msg.key.id}`)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { requestElevenLabsMp3Stream, synthesizeElevenLabsMp3 } from './elevenlabs'
|
|
4
|
+
|
|
5
|
+
describe('elevenlabs helpers', () => {
|
|
6
|
+
it('synthesizeElevenLabsMp3 posts TTS request and returns audio bytes', async () => {
|
|
7
|
+
const originalFetch = global.fetch
|
|
8
|
+
const originalKey = process.env.ELEVENLABS_API_KEY
|
|
9
|
+
const originalVoice = process.env.ELEVENLABS_VOICE
|
|
10
|
+
process.env.ELEVENLABS_API_KEY = 'test-key'
|
|
11
|
+
process.env.ELEVENLABS_VOICE = 'voice-123'
|
|
12
|
+
|
|
13
|
+
let called = false
|
|
14
|
+
global.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
15
|
+
called = true
|
|
16
|
+
assert.equal(String(input), 'https://api.elevenlabs.io/v1/text-to-speech/voice-123')
|
|
17
|
+
assert.equal(init?.method, 'POST')
|
|
18
|
+
assert.equal((init?.headers as Record<string, string>)['xi-api-key'], 'test-key')
|
|
19
|
+
return new Response(Buffer.from('abc'), { status: 200, headers: { 'Content-Type': 'audio/mpeg' } })
|
|
20
|
+
}) as typeof fetch
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const out = await synthesizeElevenLabsMp3({ text: 'hello world' })
|
|
24
|
+
assert.ok(called)
|
|
25
|
+
assert.equal(out.toString('utf8'), 'abc')
|
|
26
|
+
} finally {
|
|
27
|
+
global.fetch = originalFetch
|
|
28
|
+
if (originalKey === undefined) delete process.env.ELEVENLABS_API_KEY
|
|
29
|
+
else process.env.ELEVENLABS_API_KEY = originalKey
|
|
30
|
+
if (originalVoice === undefined) delete process.env.ELEVENLABS_VOICE
|
|
31
|
+
else process.env.ELEVENLABS_VOICE = originalVoice
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('requestElevenLabsMp3Stream calls streaming endpoint', async () => {
|
|
36
|
+
const originalFetch = global.fetch
|
|
37
|
+
const originalKey = process.env.ELEVENLABS_API_KEY
|
|
38
|
+
const originalVoice = process.env.ELEVENLABS_VOICE
|
|
39
|
+
process.env.ELEVENLABS_API_KEY = 'test-key'
|
|
40
|
+
process.env.ELEVENLABS_VOICE = 'voice-xyz'
|
|
41
|
+
|
|
42
|
+
global.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
43
|
+
assert.equal(String(input), 'https://api.elevenlabs.io/v1/text-to-speech/voice-xyz/stream')
|
|
44
|
+
assert.equal(init?.method, 'POST')
|
|
45
|
+
return new Response('stream', { status: 200, headers: { 'Content-Type': 'audio/mpeg' } })
|
|
46
|
+
}) as typeof fetch
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const res = await requestElevenLabsMp3Stream({ text: 'streaming text' })
|
|
50
|
+
assert.equal(res.status, 200)
|
|
51
|
+
assert.equal(await res.text(), 'stream')
|
|
52
|
+
} finally {
|
|
53
|
+
global.fetch = originalFetch
|
|
54
|
+
if (originalKey === undefined) delete process.env.ELEVENLABS_API_KEY
|
|
55
|
+
else process.env.ELEVENLABS_API_KEY = originalKey
|
|
56
|
+
if (originalVoice === undefined) delete process.env.ELEVENLABS_VOICE
|
|
57
|
+
else process.env.ELEVENLABS_VOICE = originalVoice
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
})
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { loadSettings } from './storage'
|
|
2
|
+
|
|
3
|
+
const DEFAULT_VOICE_ID = 'JBFqnCBsd6RMkjVDRZzb'
|
|
4
|
+
const DEFAULT_MODEL_ID = 'eleven_multilingual_v2'
|
|
5
|
+
|
|
6
|
+
function getErrorMessage(err: unknown): string {
|
|
7
|
+
if (err instanceof Error && err.message) return err.message
|
|
8
|
+
return String(err)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function resolveElevenLabsConfig(voiceId?: string | null): {
|
|
12
|
+
apiKey: string
|
|
13
|
+
voiceId: string
|
|
14
|
+
} {
|
|
15
|
+
const settings = loadSettings()
|
|
16
|
+
const apiKey = String(settings.elevenLabsApiKey || process.env.ELEVENLABS_API_KEY || '').trim()
|
|
17
|
+
if (!apiKey) {
|
|
18
|
+
throw new Error('No ElevenLabs API key. Set one in Settings > Voice.')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const resolvedVoiceId = String(
|
|
22
|
+
voiceId
|
|
23
|
+
|| settings.elevenLabsVoiceId
|
|
24
|
+
|| process.env.ELEVENLABS_VOICE
|
|
25
|
+
|| DEFAULT_VOICE_ID,
|
|
26
|
+
).trim()
|
|
27
|
+
|
|
28
|
+
return { apiKey, voiceId: resolvedVoiceId || DEFAULT_VOICE_ID }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function synthesizeElevenLabsMp3(params: {
|
|
32
|
+
text: string
|
|
33
|
+
voiceId?: string | null
|
|
34
|
+
stability?: number
|
|
35
|
+
similarityBoost?: number
|
|
36
|
+
}): Promise<Buffer> {
|
|
37
|
+
const text = params.text.trim()
|
|
38
|
+
if (!text) throw new Error('No text provided for ElevenLabs synthesis.')
|
|
39
|
+
|
|
40
|
+
const { apiKey, voiceId } = resolveElevenLabsConfig(params.voiceId)
|
|
41
|
+
const stability = Number.isFinite(params.stability) ? Math.max(0, Math.min(1, Number(params.stability))) : 0.5
|
|
42
|
+
const similarityBoost = Number.isFinite(params.similarityBoost) ? Math.max(0, Math.min(1, Number(params.similarityBoost))) : 0.75
|
|
43
|
+
|
|
44
|
+
const apiRes = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`, {
|
|
45
|
+
method: 'POST',
|
|
46
|
+
headers: {
|
|
47
|
+
'xi-api-key': apiKey,
|
|
48
|
+
'Content-Type': 'application/json',
|
|
49
|
+
Accept: 'audio/mpeg',
|
|
50
|
+
},
|
|
51
|
+
body: JSON.stringify({
|
|
52
|
+
text,
|
|
53
|
+
model_id: DEFAULT_MODEL_ID,
|
|
54
|
+
voice_settings: {
|
|
55
|
+
stability,
|
|
56
|
+
similarity_boost: similarityBoost,
|
|
57
|
+
},
|
|
58
|
+
}),
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
if (!apiRes.ok) {
|
|
62
|
+
const errBody = await apiRes.text().catch(() => '')
|
|
63
|
+
throw new Error(errBody || `ElevenLabs request failed (${apiRes.status})`)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const audioBuffer = await apiRes.arrayBuffer()
|
|
67
|
+
return Buffer.from(audioBuffer)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function requestElevenLabsMp3Stream(params: {
|
|
71
|
+
text: string
|
|
72
|
+
voiceId?: string | null
|
|
73
|
+
}): Promise<Response> {
|
|
74
|
+
const text = params.text.trim()
|
|
75
|
+
if (!text) throw new Error('No text provided for ElevenLabs stream.')
|
|
76
|
+
|
|
77
|
+
const { apiKey, voiceId } = resolveElevenLabsConfig(params.voiceId)
|
|
78
|
+
const apiRes = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${voiceId}/stream`, {
|
|
79
|
+
method: 'POST',
|
|
80
|
+
headers: {
|
|
81
|
+
'xi-api-key': apiKey,
|
|
82
|
+
'Content-Type': 'application/json',
|
|
83
|
+
Accept: 'audio/mpeg',
|
|
84
|
+
},
|
|
85
|
+
body: JSON.stringify({
|
|
86
|
+
text: text.slice(0, 2000),
|
|
87
|
+
model_id: DEFAULT_MODEL_ID,
|
|
88
|
+
voice_settings: { stability: 0.5, similarity_boost: 0.75 },
|
|
89
|
+
output_format: 'mp3_22050_32',
|
|
90
|
+
}),
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
if (!apiRes.ok) {
|
|
94
|
+
const errBody = await apiRes.text().catch(() => '')
|
|
95
|
+
throw new Error(errBody || `ElevenLabs streaming request failed (${apiRes.status})`)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return apiRes
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function explainElevenLabsError(err: unknown): string {
|
|
102
|
+
return getErrorMessage(err)
|
|
103
|
+
}
|
|
@@ -163,7 +163,13 @@ function buildAgentHeartbeatPrompt(session: any, agent: any, fallbackPrompt: str
|
|
|
163
163
|
const goalSummary = systemPrompt.slice(0, 500)
|
|
164
164
|
const recentMessages = (session.messages || []).slice(-5)
|
|
165
165
|
const recentContext = recentMessages
|
|
166
|
-
.map((m: any) =>
|
|
166
|
+
.map((m: any) => {
|
|
167
|
+
const text = (m.text || '').slice(0, 200)
|
|
168
|
+
const tools = Array.isArray(m.toolEvents) && m.toolEvents.length > 0
|
|
169
|
+
? ` [tools used: ${m.toolEvents.map((t: { name: string }) => t.name).join(', ')}]`
|
|
170
|
+
: ''
|
|
171
|
+
return `[${m.role}]: ${text}${tools}`
|
|
172
|
+
})
|
|
167
173
|
.join('\n')
|
|
168
174
|
|
|
169
175
|
// Don't inject effectively-empty HEARTBEAT.md content
|
|
@@ -187,6 +193,7 @@ function buildAgentHeartbeatPrompt(session: any, agent: any, fallbackPrompt: str
|
|
|
187
193
|
'You are running an autonomous heartbeat tick. Review your goal and recent context.',
|
|
188
194
|
'If there is meaningful work to do toward your goal, use your tools and take action.',
|
|
189
195
|
'If nothing needs attention right now, reply exactly HEARTBEAT_OK.',
|
|
196
|
+
'IMPORTANT: Do NOT repeat actions you already performed in recent context. If you already searched for something or completed a task (shown above), report your findings or reply HEARTBEAT_OK — do not search or act again unless there is a NEW reason to do so.',
|
|
190
197
|
'Do not ask clarifying questions. Take the most reasonable next action.',
|
|
191
198
|
'',
|
|
192
199
|
'To update your goal or plan, include this line in your response:',
|
|
@@ -728,7 +728,7 @@ export function stripMainLoopMetaForPersistence(text: string, internal: boolean)
|
|
|
728
728
|
if (!text) return ''
|
|
729
729
|
return text
|
|
730
730
|
.split('\n')
|
|
731
|
-
.filter((line) => !line.includes('[MAIN_LOOP_META]') && !line.includes('[MAIN_LOOP_PLAN]') && !line.includes('[MAIN_LOOP_REVIEW]'))
|
|
731
|
+
.filter((line) => !line.includes('[MAIN_LOOP_META]') && !line.includes('[MAIN_LOOP_PLAN]') && !line.includes('[MAIN_LOOP_REVIEW]') && !line.includes('[AGENT_HEARTBEAT_META]'))
|
|
732
732
|
.join('\n')
|
|
733
733
|
.trim()
|
|
734
734
|
}
|
|
@@ -38,10 +38,14 @@ export async function runDailyConsolidation(): Promise<{
|
|
|
38
38
|
|
|
39
39
|
if (candidates.length < 5) continue
|
|
40
40
|
|
|
41
|
+
// Sort by reinforcement count descending so most-reinforced memories are prioritized in digest
|
|
42
|
+
candidates.sort((a, b) => (b.reinforcementCount || 0) - (a.reinforcementCount || 0))
|
|
43
|
+
|
|
41
44
|
// Build summarization prompt
|
|
42
45
|
const memoryLines = candidates.slice(0, 30).map((m) => {
|
|
46
|
+
const rc = m.reinforcementCount || 0
|
|
43
47
|
const content = (m.content || '').slice(0, 300)
|
|
44
|
-
return `- [${m.category}] ${m.title}: ${content}`
|
|
48
|
+
return `- [${m.category}]${rc > 0 ? ` (reinforced x${rc})` : ''} ${m.title}: ${content}`
|
|
45
49
|
})
|
|
46
50
|
|
|
47
51
|
const prompt = [
|
|
@@ -65,7 +69,8 @@ export async function runDailyConsolidation(): Promise<{
|
|
|
65
69
|
|
|
66
70
|
if (!digestContent.trim()) continue
|
|
67
71
|
|
|
68
|
-
const
|
|
72
|
+
const digestCandidates = candidates.slice(0, 30)
|
|
73
|
+
const linkedMemoryIds = digestCandidates.slice(0, 10).map((m) => m.id)
|
|
69
74
|
memDb.add({
|
|
70
75
|
agentId,
|
|
71
76
|
sessionId: null,
|
|
@@ -74,6 +79,14 @@ export async function runDailyConsolidation(): Promise<{
|
|
|
74
79
|
content: digestContent.trim(),
|
|
75
80
|
linkedMemoryIds,
|
|
76
81
|
})
|
|
82
|
+
|
|
83
|
+
// Reset reinforcement counts on entries folded into the digest to prevent double-counting
|
|
84
|
+
for (const m of digestCandidates) {
|
|
85
|
+
if (m.reinforcementCount && m.reinforcementCount > 0) {
|
|
86
|
+
memDb.update(m.id, { reinforcementCount: 0 })
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
77
90
|
digestsCreated++
|
|
78
91
|
} catch (err: unknown) {
|
|
79
92
|
errors.push(`Agent ${agentId}: ${err instanceof Error ? err.message : String(err)}`)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import Database from 'better-sqlite3'
|
|
2
2
|
import path from 'path'
|
|
3
3
|
import fs from 'fs'
|
|
4
|
+
import { createHash } from 'crypto'
|
|
4
5
|
import { genId } from '@/lib/id'
|
|
5
6
|
import type { MemoryEntry, FileReference, MemoryImage, MemoryReference } from '@/types'
|
|
6
7
|
import { getEmbedding, cosineSimilarity, serializeEmbedding, deserializeEmbedding } from './embeddings'
|
|
@@ -32,6 +33,11 @@ export const MEMORY_FTS_STOP_WORDS = new Set([
|
|
|
32
33
|
'you', 'your',
|
|
33
34
|
])
|
|
34
35
|
|
|
36
|
+
function computeContentHash(category: string, content: string): string {
|
|
37
|
+
const normalized = `${category}|${content.toLowerCase().trim()}`
|
|
38
|
+
return createHash('sha256').update(normalized).digest('hex').slice(0, 16)
|
|
39
|
+
}
|
|
40
|
+
|
|
35
41
|
function shouldSkipSearchQuery(input: string): boolean {
|
|
36
42
|
const text = String(input || '').toLowerCase().trim()
|
|
37
43
|
if (!text) return true
|
|
@@ -357,6 +363,10 @@ function initDb() {
|
|
|
357
363
|
'image TEXT',
|
|
358
364
|
'pinned INTEGER DEFAULT 0',
|
|
359
365
|
'sharedWith TEXT',
|
|
366
|
+
'accessCount INTEGER DEFAULT 0',
|
|
367
|
+
'lastAccessedAt INTEGER DEFAULT 0',
|
|
368
|
+
'contentHash TEXT',
|
|
369
|
+
'reinforcementCount INTEGER DEFAULT 0',
|
|
360
370
|
]) {
|
|
361
371
|
try { db.exec(`ALTER TABLE memories ADD COLUMN ${col}`) } catch { /* already exists */ }
|
|
362
372
|
}
|
|
@@ -364,6 +374,9 @@ function initDb() {
|
|
|
364
374
|
// Partial index for fast pinned-memory lookups
|
|
365
375
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_memories_pinned ON memories(agentId, updatedAt DESC) WHERE pinned = 1`)
|
|
366
376
|
|
|
377
|
+
// Index for content hash dedup lookups
|
|
378
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_memories_content_hash ON memories(contentHash) WHERE contentHash IS NOT NULL`)
|
|
379
|
+
|
|
367
380
|
// FTS5 virtual table for full-text search
|
|
368
381
|
db.exec(`
|
|
369
382
|
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
|
|
@@ -447,6 +460,24 @@ function initDb() {
|
|
|
447
460
|
})
|
|
448
461
|
migrateLegacyRows()
|
|
449
462
|
|
|
463
|
+
// Backfill contentHash for existing rows that don't have one yet
|
|
464
|
+
const unhashed = (db.prepare(`SELECT COUNT(*) as cnt FROM memories WHERE contentHash IS NULL`).get() as { cnt: number }).cnt
|
|
465
|
+
if (unhashed > 0) {
|
|
466
|
+
const backfillRows = db.prepare(`SELECT id, category, content FROM memories WHERE contentHash IS NULL`).all() as Array<{ id: string; category: string; content: string }>
|
|
467
|
+
const backfillStmt = db.prepare(`UPDATE memories SET contentHash = ? WHERE id = ?`)
|
|
468
|
+
const BATCH = 500
|
|
469
|
+
for (let i = 0; i < backfillRows.length; i += BATCH) {
|
|
470
|
+
const batch = backfillRows.slice(i, i + BATCH)
|
|
471
|
+
const tx = db.transaction(() => {
|
|
472
|
+
for (const r of batch) {
|
|
473
|
+
backfillStmt.run(computeContentHash(r.category, r.content), r.id)
|
|
474
|
+
}
|
|
475
|
+
})
|
|
476
|
+
tx()
|
|
477
|
+
}
|
|
478
|
+
console.log(`[memory-db] Backfilled contentHash for ${backfillRows.length} memory row(s)`)
|
|
479
|
+
}
|
|
480
|
+
|
|
450
481
|
// Fresh installs now start with an empty memory graph.
|
|
451
482
|
// Durable memories are created only from actual user/agent interactions.
|
|
452
483
|
|
|
@@ -454,9 +485,9 @@ function initDb() {
|
|
|
454
485
|
insert: db.prepare(`
|
|
455
486
|
INSERT INTO memories (
|
|
456
487
|
id, agentId, sessionId, category, title, content, metadata, embedding,
|
|
457
|
-
"references", filePaths, image, imagePath, linkedMemoryIds, pinned, sharedWith, createdAt, updatedAt
|
|
488
|
+
"references", filePaths, image, imagePath, linkedMemoryIds, pinned, sharedWith, contentHash, createdAt, updatedAt
|
|
458
489
|
)
|
|
459
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
490
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
460
491
|
`),
|
|
461
492
|
update: db.prepare(`
|
|
462
493
|
UPDATE memories
|
|
@@ -511,6 +542,24 @@ function initDb() {
|
|
|
511
542
|
ORDER BY updatedAt DESC
|
|
512
543
|
LIMIT 1
|
|
513
544
|
`),
|
|
545
|
+
findByContentHash: db.prepare(`
|
|
546
|
+
SELECT * FROM memories
|
|
547
|
+
WHERE contentHash = ? AND agentId = ?
|
|
548
|
+
ORDER BY updatedAt DESC
|
|
549
|
+
LIMIT 1
|
|
550
|
+
`),
|
|
551
|
+
findByContentHashShared: db.prepare(`
|
|
552
|
+
SELECT * FROM memories
|
|
553
|
+
WHERE contentHash = ? AND agentId IS NULL
|
|
554
|
+
ORDER BY updatedAt DESC
|
|
555
|
+
LIMIT 1
|
|
556
|
+
`),
|
|
557
|
+
reinforceMemory: db.prepare(`
|
|
558
|
+
UPDATE memories SET reinforcementCount = reinforcementCount + 1, updatedAt = ? WHERE id = ?
|
|
559
|
+
`),
|
|
560
|
+
bumpAccessCount: db.prepare(`
|
|
561
|
+
UPDATE memories SET accessCount = accessCount + 1, lastAccessedAt = ? WHERE id = ?
|
|
562
|
+
`),
|
|
514
563
|
}
|
|
515
564
|
|
|
516
565
|
function rowToEntry(row: Record<string, unknown>): MemoryEntry {
|
|
@@ -535,6 +584,10 @@ function initDb() {
|
|
|
535
584
|
linkedMemoryIds: linkedMemoryIds.length ? linkedMemoryIds : undefined,
|
|
536
585
|
pinned: row.pinned === 1,
|
|
537
586
|
sharedWith: parseJsonSafe<string[]>(row.sharedWith, []).length ? parseJsonSafe<string[]>(row.sharedWith, []) : undefined,
|
|
587
|
+
accessCount: typeof row.accessCount === 'number' ? row.accessCount : 0,
|
|
588
|
+
lastAccessedAt: typeof row.lastAccessedAt === 'number' ? row.lastAccessedAt : 0,
|
|
589
|
+
contentHash: typeof row.contentHash === 'string' ? row.contentHash : undefined,
|
|
590
|
+
reinforcementCount: typeof row.reinforcementCount === 'number' ? row.reinforcementCount : 0,
|
|
538
591
|
createdAt: typeof row.createdAt === 'number' ? row.createdAt : Date.now(),
|
|
539
592
|
updatedAt: typeof row.updatedAt === 'number' ? row.updatedAt : Date.now(),
|
|
540
593
|
}
|
|
@@ -574,6 +627,17 @@ function initDb() {
|
|
|
574
627
|
const category = data.category || 'note'
|
|
575
628
|
const title = data.title || 'Untitled'
|
|
576
629
|
const content = data.content || ''
|
|
630
|
+
const contentHash = computeContentHash(category, content)
|
|
631
|
+
|
|
632
|
+
// Content-hash dedup: if same content already exists for this agent, reinforce instead of duplicating
|
|
633
|
+
const agentId = data.agentId || null
|
|
634
|
+
const existingByHash = agentId
|
|
635
|
+
? stmts.findByContentHash.get(contentHash, agentId) as Record<string, unknown> | undefined
|
|
636
|
+
: stmts.findByContentHashShared.get(contentHash) as Record<string, unknown> | undefined
|
|
637
|
+
if (existingByHash) {
|
|
638
|
+
stmts.reinforceMemory.run(now, existingByHash.id)
|
|
639
|
+
return rowToEntry({ ...existingByHash, reinforcementCount: ((existingByHash.reinforcementCount as number) || 0) + 1, updatedAt: now })
|
|
640
|
+
}
|
|
577
641
|
|
|
578
642
|
// Guard against exact duplicate memory spam for the same session/category.
|
|
579
643
|
if (sessionId) {
|
|
@@ -583,7 +647,7 @@ function initDb() {
|
|
|
583
647
|
const pinned = data.pinned ? 1 : 0
|
|
584
648
|
const sharedWith = Array.isArray(data.sharedWith) && data.sharedWith.length ? JSON.stringify(data.sharedWith) : null
|
|
585
649
|
stmts.insert.run(
|
|
586
|
-
id,
|
|
650
|
+
id, agentId, sessionId,
|
|
587
651
|
category, title, content,
|
|
588
652
|
data.metadata ? JSON.stringify(data.metadata) : null,
|
|
589
653
|
null, // embedding computed async
|
|
@@ -594,6 +658,7 @@ function initDb() {
|
|
|
594
658
|
linkedMemoryIds.length ? JSON.stringify(linkedMemoryIds) : null,
|
|
595
659
|
pinned,
|
|
596
660
|
sharedWith,
|
|
661
|
+
contentHash,
|
|
597
662
|
now, now,
|
|
598
663
|
)
|
|
599
664
|
// Compute embedding in background (fire-and-forget)
|
|
@@ -623,6 +688,10 @@ function initDb() {
|
|
|
623
688
|
image,
|
|
624
689
|
imagePath: image?.path || null,
|
|
625
690
|
linkedMemoryIds,
|
|
691
|
+
accessCount: 0,
|
|
692
|
+
lastAccessedAt: 0,
|
|
693
|
+
contentHash,
|
|
694
|
+
reinforcementCount: 0,
|
|
626
695
|
createdAt: now,
|
|
627
696
|
updatedAt: now,
|
|
628
697
|
}
|
|
@@ -699,6 +768,10 @@ function initDb() {
|
|
|
699
768
|
get(id: string): MemoryEntry | null {
|
|
700
769
|
const row = stmts.getById.get(id) as Record<string, unknown> | undefined
|
|
701
770
|
if (!row) return null
|
|
771
|
+
// Bump access count (non-blocking)
|
|
772
|
+
setTimeout(() => {
|
|
773
|
+
try { stmts.bumpAccessCount.run(Date.now(), id) } catch { /* best-effort */ }
|
|
774
|
+
}, 0)
|
|
702
775
|
return rowToEntry(row)
|
|
703
776
|
},
|
|
704
777
|
|
|
@@ -791,6 +864,7 @@ function initDb() {
|
|
|
791
864
|
: []
|
|
792
865
|
|
|
793
866
|
// Attempt vector search (synchronous — uses cached embedding if available)
|
|
867
|
+
const vectorSimilarityScores = new Map<string, number>()
|
|
794
868
|
let vectorResults: MemoryEntry[] = []
|
|
795
869
|
try {
|
|
796
870
|
const queryEmbedding = getEmbeddingSync(query)
|
|
@@ -809,13 +883,17 @@ function initDb() {
|
|
|
809
883
|
.sort((a, b) => b.score - a.score)
|
|
810
884
|
.slice(0, 20)
|
|
811
885
|
|
|
812
|
-
vectorResults = scored.map((s) =>
|
|
886
|
+
vectorResults = scored.map((s) => {
|
|
887
|
+
const entry = rowToEntry(s.row)
|
|
888
|
+
vectorSimilarityScores.set(entry.id, s.score)
|
|
889
|
+
return entry
|
|
890
|
+
})
|
|
813
891
|
}
|
|
814
892
|
} catch {
|
|
815
893
|
// Vector search unavailable, use FTS only
|
|
816
894
|
}
|
|
817
895
|
|
|
818
|
-
// Merge: deduplicate by id
|
|
896
|
+
// Merge: deduplicate by id
|
|
819
897
|
const seen = new Set<string>()
|
|
820
898
|
const merged: MemoryEntry[] = []
|
|
821
899
|
for (const entry of [...ftsResults, ...vectorResults]) {
|
|
@@ -824,7 +902,34 @@ function initDb() {
|
|
|
824
902
|
merged.push(entry)
|
|
825
903
|
}
|
|
826
904
|
}
|
|
827
|
-
|
|
905
|
+
|
|
906
|
+
// Apply salience scoring: similarity * recencyDecay * reinforcement * pinnedBoost
|
|
907
|
+
const now = Date.now()
|
|
908
|
+
const HALF_LIFE_DAYS = 30
|
|
909
|
+
const salienceScored = merged.map((entry) => {
|
|
910
|
+
const similarity = vectorSimilarityScores.get(entry.id) ?? 0.5
|
|
911
|
+
const daysSinceAccess = (now - (entry.lastAccessedAt || entry.updatedAt)) / 86_400_000
|
|
912
|
+
const recencyDecay = Math.exp(-0.693 * daysSinceAccess / HALF_LIFE_DAYS)
|
|
913
|
+
const reinforcement = Math.log((entry.reinforcementCount || 0) + 1) + 1
|
|
914
|
+
const pinnedBoost = entry.pinned ? 1.5 : 1.0
|
|
915
|
+
const salience = similarity * recencyDecay * reinforcement * pinnedBoost
|
|
916
|
+
return { entry, salience }
|
|
917
|
+
})
|
|
918
|
+
salienceScored.sort((a, b) => b.salience - a.salience)
|
|
919
|
+
|
|
920
|
+
const out = salienceScored.slice(0, MAX_MERGED_RESULTS).map((s) => s.entry)
|
|
921
|
+
|
|
922
|
+
// Bump access counts for returned results (non-blocking)
|
|
923
|
+
if (out.length) {
|
|
924
|
+
const returnedIds = out.map((e) => e.id)
|
|
925
|
+
setTimeout(() => {
|
|
926
|
+
try {
|
|
927
|
+
const ts = Date.now()
|
|
928
|
+
for (const mid of returnedIds) stmts.bumpAccessCount.run(ts, mid)
|
|
929
|
+
} catch { /* best-effort */ }
|
|
930
|
+
}, 0)
|
|
931
|
+
}
|
|
932
|
+
|
|
828
933
|
const elapsed = Date.now() - startedAt
|
|
829
934
|
if (elapsed > 1200) {
|
|
830
935
|
console.warn(
|
|
@@ -965,9 +1070,32 @@ function initDb() {
|
|
|
965
1070
|
const pruneWorking = options.pruneWorking !== false
|
|
966
1071
|
const cutoff = Date.now() - Math.max(1, Math.min(24 * 365, Math.trunc(options.ttlHours || 24))) * 3600_000
|
|
967
1072
|
|
|
1073
|
+
// Hash-based dedup: group by contentHash + agentId, keep the one with highest reinforcementCount
|
|
1074
|
+
if (dedupe && toDelete.size < deleteBudget) {
|
|
1075
|
+
const hashGroups = new Map<string, MemoryEntry[]>()
|
|
1076
|
+
for (const row of rows) {
|
|
1077
|
+
if (!row.contentHash || toDelete.has(row.id)) continue
|
|
1078
|
+
const groupKey = `${row.agentId || ''}|${row.contentHash}`
|
|
1079
|
+
const group = hashGroups.get(groupKey)
|
|
1080
|
+
if (group) group.push(row)
|
|
1081
|
+
else hashGroups.set(groupKey, [row])
|
|
1082
|
+
}
|
|
1083
|
+
for (const group of hashGroups.values()) {
|
|
1084
|
+
if (group.length <= 1) continue
|
|
1085
|
+
group.sort((a, b) => (b.reinforcementCount || 0) - (a.reinforcementCount || 0))
|
|
1086
|
+
for (let i = 1; i < group.length; i++) {
|
|
1087
|
+
toDelete.add(group[i].id)
|
|
1088
|
+
if (toDelete.size >= deleteBudget) break
|
|
1089
|
+
}
|
|
1090
|
+
if (toDelete.size >= deleteBudget) break
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
// Exact string-match dedup (legacy fallback for rows without contentHash)
|
|
968
1095
|
if (dedupe) {
|
|
969
1096
|
const seen = new Set<string>()
|
|
970
1097
|
for (const row of rows) {
|
|
1098
|
+
if (toDelete.has(row.id)) continue
|
|
971
1099
|
const key = [
|
|
972
1100
|
row.agentId || '',
|
|
973
1101
|
row.sessionId || '',
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export const MIME_TYPES: Record<string, string> = {
|
|
2
|
+
'.png': 'image/png',
|
|
3
|
+
'.jpg': 'image/jpeg',
|
|
4
|
+
'.jpeg': 'image/jpeg',
|
|
5
|
+
'.gif': 'image/gif',
|
|
6
|
+
'.webp': 'image/webp',
|
|
7
|
+
'.svg': 'image/svg+xml',
|
|
8
|
+
'.bmp': 'image/bmp',
|
|
9
|
+
'.ico': 'image/x-icon',
|
|
10
|
+
'.mp4': 'video/mp4',
|
|
11
|
+
'.webm': 'video/webm',
|
|
12
|
+
'.mov': 'video/quicktime',
|
|
13
|
+
'.avi': 'video/x-msvideo',
|
|
14
|
+
'.mkv': 'video/x-matroska',
|
|
15
|
+
'.pdf': 'application/pdf',
|
|
16
|
+
'.json': 'application/json',
|
|
17
|
+
'.csv': 'text/csv',
|
|
18
|
+
'.txt': 'text/plain',
|
|
19
|
+
'.html': 'text/html',
|
|
20
|
+
'.xml': 'application/xml',
|
|
21
|
+
'.zip': 'application/zip',
|
|
22
|
+
'.tar': 'application/x-tar',
|
|
23
|
+
'.gz': 'application/gzip',
|
|
24
|
+
'.doc': 'application/msword',
|
|
25
|
+
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
26
|
+
'.xls': 'application/vnd.ms-excel',
|
|
27
|
+
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
28
|
+
'.ppt': 'application/vnd.ms-powerpoint',
|
|
29
|
+
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
30
|
+
'.mp3': 'audio/mpeg',
|
|
31
|
+
'.wav': 'audio/wav',
|
|
32
|
+
'.ogg': 'audio/ogg',
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico'])
|
|
36
|
+
const VIDEO_EXTS = new Set(['.mp4', '.webm', '.mov', '.avi', '.mkv'])
|
|
37
|
+
const AUDIO_EXTS = new Set(['.mp3', '.wav', '.ogg'])
|
|
38
|
+
const DOCUMENT_EXTS = new Set(['.pdf', '.json', '.csv', '.txt', '.html', '.xml', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx'])
|
|
39
|
+
const ARCHIVE_EXTS = new Set(['.zip', '.tar', '.gz'])
|
|
40
|
+
|
|
41
|
+
export type FileCategory = 'image' | 'video' | 'audio' | 'document' | 'archive' | 'other'
|
|
42
|
+
|
|
43
|
+
export function getFileCategory(ext: string): FileCategory {
|
|
44
|
+
const lower = ext.toLowerCase()
|
|
45
|
+
if (IMAGE_EXTS.has(lower)) return 'image'
|
|
46
|
+
if (VIDEO_EXTS.has(lower)) return 'video'
|
|
47
|
+
if (AUDIO_EXTS.has(lower)) return 'audio'
|
|
48
|
+
if (DOCUMENT_EXTS.has(lower)) return 'document'
|
|
49
|
+
if (ARCHIVE_EXTS.has(lower)) return 'archive'
|
|
50
|
+
return 'other'
|
|
51
|
+
}
|