cognova 0.1.0
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/.env.example +58 -0
- package/Claude/CLAUDE.md +92 -0
- package/Claude/hooks/lib/__init__.py +1 -0
- package/Claude/hooks/lib/hook_client.py +207 -0
- package/Claude/hooks/log-event.py +97 -0
- package/Claude/hooks/pre-compact.py +46 -0
- package/Claude/hooks/session-end.py +26 -0
- package/Claude/hooks/session-start.py +35 -0
- package/Claude/hooks/stop-extract.py +40 -0
- package/Claude/rules/frontmatter.md +54 -0
- package/Claude/rules/markdown.md +43 -0
- package/Claude/rules/note-organization.md +33 -0
- package/Claude/settings.json +54 -0
- package/Claude/skills/README.md +136 -0
- package/Claude/skills/_lib/__init__.py +1 -0
- package/Claude/skills/_lib/api.py +164 -0
- package/Claude/skills/_lib/output.py +95 -0
- package/Claude/skills/environment/SKILL.md +73 -0
- package/Claude/skills/environment/environment.py +239 -0
- package/Claude/skills/memory/SKILL.md +153 -0
- package/Claude/skills/memory/memory.py +270 -0
- package/Claude/skills/project/SKILL.md +105 -0
- package/Claude/skills/project/project.py +203 -0
- package/Claude/skills/skill-creator/SKILL.md +261 -0
- package/Claude/skills/task/SKILL.md +135 -0
- package/Claude/skills/task/task.py +310 -0
- package/LICENSE +21 -0
- package/README.md +176 -0
- package/app/app.config.ts +8 -0
- package/app/app.vue +39 -0
- package/app/assets/css/main.css +10 -0
- package/app/components/AppLogo.vue +40 -0
- package/app/components/AssistantPanel.client.vue +518 -0
- package/app/components/ConfirmModal.vue +84 -0
- package/app/components/TemplateMenu.vue +49 -0
- package/app/components/agents/AgentActivityChart.client.vue +105 -0
- package/app/components/agents/AgentActivityChart.server.vue +25 -0
- package/app/components/agents/AgentForm.vue +304 -0
- package/app/components/agents/AgentRunModal.vue +154 -0
- package/app/components/agents/AgentStatsCards.vue +98 -0
- package/app/components/chat/ChatInput.vue +85 -0
- package/app/components/chat/ConversationList.vue +78 -0
- package/app/components/chat/MessageBubble.vue +81 -0
- package/app/components/chat/StreamingMessage.vue +36 -0
- package/app/components/chat/ToolCallBlock.vue +77 -0
- package/app/components/editor/CodeEditor.client.vue +212 -0
- package/app/components/editor/CodeEditorFallback.vue +12 -0
- package/app/components/editor/DocumentEditor.vue +326 -0
- package/app/components/editor/DocumentMetadata.vue +140 -0
- package/app/components/editor/MarkdownEditor.vue +146 -0
- package/app/components/files/FileTree.vue +436 -0
- package/app/components/hooks/HookActivityChart.client.vue +117 -0
- package/app/components/hooks/HookActivityChart.server.vue +25 -0
- package/app/components/hooks/HookStatsCards.vue +63 -0
- package/app/components/hooks/RecentEventsTable.vue +123 -0
- package/app/components/hooks/ToolBreakdownTable.vue +72 -0
- package/app/components/search/DashboardSearch.vue +122 -0
- package/app/components/tasks/ProjectSelect.vue +35 -0
- package/app/components/tasks/TaskCard.vue +182 -0
- package/app/components/tasks/TaskDetail.vue +160 -0
- package/app/components/tasks/TaskForm.vue +280 -0
- package/app/components/tasks/TaskList.vue +69 -0
- package/app/components/view/ViewToc.vue +85 -0
- package/app/composables/useAgents.ts +153 -0
- package/app/composables/useAuth.ts +73 -0
- package/app/composables/useChat.ts +298 -0
- package/app/composables/useDocument.ts +141 -0
- package/app/composables/useEditor.ts +100 -0
- package/app/composables/useFileTree.ts +220 -0
- package/app/composables/useHookEvents.ts +68 -0
- package/app/composables/useMemories.ts +83 -0
- package/app/composables/useNotificationBus.ts +154 -0
- package/app/composables/usePreferences.ts +131 -0
- package/app/composables/useProjects.ts +97 -0
- package/app/composables/useSearch.ts +52 -0
- package/app/composables/useTasks.ts +201 -0
- package/app/composables/useTerminal.ts +135 -0
- package/app/layouts/auth.vue +20 -0
- package/app/layouts/dashboard.vue +186 -0
- package/app/layouts/view.vue +60 -0
- package/app/middleware/auth.ts +9 -0
- package/app/pages/agents/[id].vue +602 -0
- package/app/pages/agents/index.vue +412 -0
- package/app/pages/chat.vue +146 -0
- package/app/pages/dashboard.vue +80 -0
- package/app/pages/docs.vue +131 -0
- package/app/pages/hooks.vue +163 -0
- package/app/pages/index.vue +249 -0
- package/app/pages/login.vue +60 -0
- package/app/pages/memories.vue +282 -0
- package/app/pages/settings.vue +625 -0
- package/app/pages/tasks.vue +312 -0
- package/app/pages/view/[uuid].vue +376 -0
- package/dist/cli/index.js +2711 -0
- package/drizzle.config.ts +10 -0
- package/nuxt.config.ts +98 -0
- package/package.json +107 -0
- package/server/api/agents/[id]/cancel.post.ts +27 -0
- package/server/api/agents/[id]/run.post.ts +34 -0
- package/server/api/agents/[id]/runs.get.ts +45 -0
- package/server/api/agents/[id]/stats.get.ts +94 -0
- package/server/api/agents/[id].delete.ts +29 -0
- package/server/api/agents/[id].get.ts +25 -0
- package/server/api/agents/[id].patch.ts +55 -0
- package/server/api/agents/index.get.ts +15 -0
- package/server/api/agents/index.post.ts +48 -0
- package/server/api/agents/stats.get.ts +86 -0
- package/server/api/auth/[...all].ts +5 -0
- package/server/api/conversations/[id].delete.ts +16 -0
- package/server/api/conversations/[id].get.ts +34 -0
- package/server/api/conversations/index.get.ts +17 -0
- package/server/api/documents/[id]/index.delete.ts +47 -0
- package/server/api/documents/[id]/index.put.ts +102 -0
- package/server/api/documents/[id]/public.get.ts +60 -0
- package/server/api/documents/[id]/restore.post.ts +65 -0
- package/server/api/documents/by-path.post.ts +168 -0
- package/server/api/documents/index.get.ts +48 -0
- package/server/api/fs/delete.post.ts +41 -0
- package/server/api/fs/list.get.ts +99 -0
- package/server/api/fs/mkdir.post.ts +44 -0
- package/server/api/fs/move.post.ts +68 -0
- package/server/api/fs/read.post.ts +48 -0
- package/server/api/fs/rename.post.ts +55 -0
- package/server/api/fs/write.post.ts +51 -0
- package/server/api/health.get.ts +40 -0
- package/server/api/home.get.ts +26 -0
- package/server/api/hooks/events/index.get.ts +56 -0
- package/server/api/hooks/events/index.post.ts +36 -0
- package/server/api/hooks/stats.get.ts +99 -0
- package/server/api/memory/[id].delete.ts +26 -0
- package/server/api/memory/context.get.ts +83 -0
- package/server/api/memory/extract.post.ts +42 -0
- package/server/api/memory/search.get.ts +70 -0
- package/server/api/memory/store.post.ts +31 -0
- package/server/api/projects/[id]/index.delete.ts +40 -0
- package/server/api/projects/[id]/index.get.ts +25 -0
- package/server/api/projects/[id]/index.put.ts +50 -0
- package/server/api/projects/index.get.ts +20 -0
- package/server/api/projects/index.post.ts +34 -0
- package/server/api/secrets/[key].delete.ts +31 -0
- package/server/api/secrets/[key].get.ts +30 -0
- package/server/api/secrets/[key].put.ts +52 -0
- package/server/api/secrets/index.get.ts +20 -0
- package/server/api/secrets/index.post.ts +58 -0
- package/server/api/tasks/[id]/index.delete.ts +46 -0
- package/server/api/tasks/[id]/index.get.ts +24 -0
- package/server/api/tasks/[id]/index.put.ts +70 -0
- package/server/api/tasks/[id]/restore.post.ts +49 -0
- package/server/api/tasks/index.get.ts +53 -0
- package/server/api/tasks/index.post.ts +47 -0
- package/server/api/tasks/tags.get.ts +21 -0
- package/server/api/user/email.patch.ts +56 -0
- package/server/db/index.ts +76 -0
- package/server/db/migrate.ts +41 -0
- package/server/db/schema.ts +345 -0
- package/server/db/seed.ts +46 -0
- package/server/db/types.ts +28 -0
- package/server/drizzle/migrations/0000_brown_george_stacy.sql +34 -0
- package/server/drizzle/migrations/0001_stormy_pyro.sql +16 -0
- package/server/drizzle/migrations/0002_clean_colossus.sql +50 -0
- package/server/drizzle/migrations/0003_fine_joystick.sql +12 -0
- package/server/drizzle/migrations/0004_tan_groot.sql +26 -0
- package/server/drizzle/migrations/0005_cloudy_lilith.sql +33 -0
- package/server/drizzle/migrations/0006_ordinary_retro_girl.sql +13 -0
- package/server/drizzle/migrations/0007_flowery_venus.sql +15 -0
- package/server/drizzle/migrations/0008_talented_zombie.sql +13 -0
- package/server/drizzle/migrations/0009_gray_shen.sql +15 -0
- package/server/drizzle/migrations/meta/0000_snapshot.json +230 -0
- package/server/drizzle/migrations/meta/0001_snapshot.json +306 -0
- package/server/drizzle/migrations/meta/0002_snapshot.json +615 -0
- package/server/drizzle/migrations/meta/0003_snapshot.json +730 -0
- package/server/drizzle/migrations/meta/0004_snapshot.json +916 -0
- package/server/drizzle/migrations/meta/0005_snapshot.json +1127 -0
- package/server/drizzle/migrations/meta/0006_snapshot.json +1213 -0
- package/server/drizzle/migrations/meta/0007_snapshot.json +1307 -0
- package/server/drizzle/migrations/meta/0008_snapshot.json +1390 -0
- package/server/drizzle/migrations/meta/0009_snapshot.json +1487 -0
- package/server/drizzle/migrations/meta/_journal.json +76 -0
- package/server/middleware/auth.ts +79 -0
- package/server/plugins/00.env-validate.ts +38 -0
- package/server/plugins/01.api-token.ts +31 -0
- package/server/plugins/02.database.ts +54 -0
- package/server/plugins/03.file-watcher.ts +65 -0
- package/server/plugins/04.cron-agents.ts +26 -0
- package/server/routes/_ws/chat.ts +252 -0
- package/server/routes/notifications.ts +47 -0
- package/server/routes/terminal.ts +98 -0
- package/server/services/agent-executor.ts +218 -0
- package/server/services/cron-scheduler.ts +78 -0
- package/server/services/memory-extractor.ts +120 -0
- package/server/utils/agent-cleanup.ts +91 -0
- package/server/utils/agent-registry.ts +95 -0
- package/server/utils/auth.ts +33 -0
- package/server/utils/chat-session-manager.ts +59 -0
- package/server/utils/crypto.ts +40 -0
- package/server/utils/db-guard.ts +12 -0
- package/server/utils/db-state.ts +63 -0
- package/server/utils/document-sync.ts +207 -0
- package/server/utils/frontmatter.ts +84 -0
- package/server/utils/notification-bus.ts +60 -0
- package/server/utils/path-validator.ts +55 -0
- package/server/utils/pty-manager.ts +130 -0
- package/shared/types/index.ts +604 -0
- package/shared/utils/language-detection.ts +87 -0
- package/tsconfig.json +10 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { betterAuth } from 'better-auth'
|
|
2
|
+
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
|
|
3
|
+
import { getDb, schema } from '../db'
|
|
4
|
+
|
|
5
|
+
export const auth = betterAuth({
|
|
6
|
+
baseURL: process.env.BETTER_AUTH_URL || 'http://localhost:3000',
|
|
7
|
+
database: drizzleAdapter(getDb(), {
|
|
8
|
+
provider: 'pg',
|
|
9
|
+
schema: {
|
|
10
|
+
user: schema.user,
|
|
11
|
+
session: schema.session,
|
|
12
|
+
account: schema.account,
|
|
13
|
+
verification: schema.verification
|
|
14
|
+
}
|
|
15
|
+
}),
|
|
16
|
+
emailAndPassword: {
|
|
17
|
+
enabled: true
|
|
18
|
+
},
|
|
19
|
+
session: {
|
|
20
|
+
expiresIn: 60 * 60 * 24 * 7, // 7 days
|
|
21
|
+
updateAge: 60 * 60 * 24 // Update session every 24 hours
|
|
22
|
+
},
|
|
23
|
+
advanced: {
|
|
24
|
+
// For HTTP (non-HTTPS) access via IP/LAN, cookies must not require Secure flag
|
|
25
|
+
useSecureCookies: process.env.BETTER_AUTH_URL?.startsWith('https://') ?? false
|
|
26
|
+
},
|
|
27
|
+
trustedOrigins: process.env.ACCESS_MODE === 'any'
|
|
28
|
+
? (request?: Request) => {
|
|
29
|
+
const origin = request?.headers.get('origin')
|
|
30
|
+
return origin ? [origin] : []
|
|
31
|
+
}
|
|
32
|
+
: process.env.BETTER_AUTH_URL ? [process.env.BETTER_AUTH_URL] : []
|
|
33
|
+
})
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { query } from '@anthropic-ai/claude-agent-sdk'
|
|
2
|
+
|
|
3
|
+
interface ActiveSession {
|
|
4
|
+
conversationId: string
|
|
5
|
+
sdkSessionId: string
|
|
6
|
+
conversation: ReturnType<typeof query>
|
|
7
|
+
interrupted: boolean
|
|
8
|
+
startedAt: Date
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
class ChatSessionManager {
|
|
12
|
+
private sessions = new Map<string, ActiveSession>()
|
|
13
|
+
|
|
14
|
+
startSession(conversationId: string, prompt: string, resumeSessionId?: string): ActiveSession {
|
|
15
|
+
// Use the project directory as CWD so the SDK picks up .claude/ (skills, rules, CLAUDE.md)
|
|
16
|
+
// The vault is accessible via VAULT_PATH env var in tools
|
|
17
|
+
const projectDir = process.env.COGNOVA_PROJECT_DIR || process.cwd()
|
|
18
|
+
const conversation = query({
|
|
19
|
+
prompt,
|
|
20
|
+
options: {
|
|
21
|
+
cwd: projectDir,
|
|
22
|
+
settingSources: ['user', 'project'],
|
|
23
|
+
permissionMode: 'bypassPermissions',
|
|
24
|
+
allowDangerouslySkipPermissions: true,
|
|
25
|
+
maxTurns: 200,
|
|
26
|
+
includePartialMessages: true,
|
|
27
|
+
...(resumeSessionId ? { resume: resumeSessionId } : {})
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const session: ActiveSession = {
|
|
32
|
+
conversationId,
|
|
33
|
+
sdkSessionId: '',
|
|
34
|
+
conversation,
|
|
35
|
+
interrupted: false,
|
|
36
|
+
startedAt: new Date()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
this.sessions.set(conversationId, session)
|
|
40
|
+
return session
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
getSession(conversationId: string): ActiveSession | undefined {
|
|
44
|
+
return this.sessions.get(conversationId)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interrupt(conversationId: string): boolean {
|
|
48
|
+
const session = this.sessions.get(conversationId)
|
|
49
|
+
if (!session) return false
|
|
50
|
+
session.interrupted = true
|
|
51
|
+
return true
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
removeSession(conversationId: string): void {
|
|
55
|
+
this.sessions.delete(conversationId)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const chatSessionManager = new ChatSessionManager()
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'crypto'
|
|
2
|
+
|
|
3
|
+
const ALGORITHM = 'aes-256-gcm'
|
|
4
|
+
const SALT = 'cognova-secrets-salt'
|
|
5
|
+
|
|
6
|
+
function deriveKey(): Buffer {
|
|
7
|
+
const secret = process.env.BETTER_AUTH_SECRET
|
|
8
|
+
if (!secret) throw new Error('BETTER_AUTH_SECRET not set')
|
|
9
|
+
return scryptSync(secret, SALT, 32)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function encryptSecret(plaintext: string): { encrypted: string, iv: string } {
|
|
13
|
+
const key = deriveKey()
|
|
14
|
+
const iv = randomBytes(16)
|
|
15
|
+
const cipher = createCipheriv(ALGORITHM, key, iv)
|
|
16
|
+
|
|
17
|
+
let encrypted = cipher.update(plaintext, 'utf8', 'hex')
|
|
18
|
+
encrypted += cipher.final('hex')
|
|
19
|
+
const authTag = cipher.getAuthTag().toString('hex')
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
encrypted: encrypted + ':' + authTag,
|
|
23
|
+
iv: iv.toString('hex')
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function decryptSecret(encrypted: string, ivHex: string): string {
|
|
28
|
+
const key = deriveKey()
|
|
29
|
+
const iv = Buffer.from(ivHex, 'hex')
|
|
30
|
+
const [ciphertext, authTag] = encrypted.split(':')
|
|
31
|
+
|
|
32
|
+
const decipher = createDecipheriv(ALGORITHM, key, iv)
|
|
33
|
+
decipher.setAuthTag(Buffer.from(authTag!, 'hex'))
|
|
34
|
+
|
|
35
|
+
const decryptedBuf = Buffer.concat([
|
|
36
|
+
decipher.update(Buffer.from(ciphertext!, 'hex')),
|
|
37
|
+
decipher.final()
|
|
38
|
+
])
|
|
39
|
+
return decryptedBuf.toString('utf8')
|
|
40
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { H3Event } from 'h3'
|
|
2
|
+
import { isDbAvailable } from './db-state'
|
|
3
|
+
|
|
4
|
+
export function requireDb(_event: H3Event) {
|
|
5
|
+
if (!isDbAvailable()) {
|
|
6
|
+
throw createError({
|
|
7
|
+
statusCode: 503,
|
|
8
|
+
statusMessage: 'Database Unavailable',
|
|
9
|
+
message: 'Database features are currently disabled. Configure DATABASE_URL to enable.'
|
|
10
|
+
})
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
interface DbState {
|
|
2
|
+
available: boolean
|
|
3
|
+
error?: string
|
|
4
|
+
lastCheck: Date
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
let dbState: DbState = {
|
|
8
|
+
available: false,
|
|
9
|
+
lastCheck: new Date()
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Track whether setDbState has been called
|
|
13
|
+
let stateInitialized = false
|
|
14
|
+
|
|
15
|
+
// Promise that resolves when DB state is first set
|
|
16
|
+
let dbReadyResolve: ((available: boolean) => void) | null = null
|
|
17
|
+
let dbReadyPromise: Promise<boolean> | null = null
|
|
18
|
+
|
|
19
|
+
export function setDbState(available: boolean, error?: string) {
|
|
20
|
+
dbState = { available, error, lastCheck: new Date() }
|
|
21
|
+
stateInitialized = true
|
|
22
|
+
|
|
23
|
+
// Resolve the waiting promise if anyone is waiting
|
|
24
|
+
if (dbReadyResolve) {
|
|
25
|
+
dbReadyResolve(available)
|
|
26
|
+
dbReadyResolve = null
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function isDbAvailable(): boolean {
|
|
31
|
+
return dbState.available
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function getDbState(): DbState {
|
|
35
|
+
return { ...dbState }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Wait for the database to be initialized.
|
|
40
|
+
* Returns true if available, false if unavailable or timeout.
|
|
41
|
+
*/
|
|
42
|
+
export function waitForDb(timeoutMs = 10000): Promise<boolean> {
|
|
43
|
+
// Already initialized
|
|
44
|
+
if (stateInitialized)
|
|
45
|
+
return Promise.resolve(dbState.available)
|
|
46
|
+
|
|
47
|
+
// Create promise if not already waiting
|
|
48
|
+
if (!dbReadyPromise) {
|
|
49
|
+
dbReadyPromise = new Promise<boolean>((resolve) => {
|
|
50
|
+
dbReadyResolve = resolve
|
|
51
|
+
|
|
52
|
+
// Timeout fallback
|
|
53
|
+
setTimeout(() => {
|
|
54
|
+
if (dbReadyResolve) {
|
|
55
|
+
dbReadyResolve(false)
|
|
56
|
+
dbReadyResolve = null
|
|
57
|
+
}
|
|
58
|
+
}, timeoutMs)
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return dbReadyPromise
|
|
63
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { readFile, readdir, writeFile } from 'fs/promises'
|
|
2
|
+
import { join, basename } from 'path'
|
|
3
|
+
import { eq, isNull } from 'drizzle-orm'
|
|
4
|
+
import { getDb } from '~~/server/db'
|
|
5
|
+
import * as schema from '~~/server/db/schema'
|
|
6
|
+
import { getVaultRoot, toRelativePath } from './path-validator'
|
|
7
|
+
import { parseFrontmatter, stringifyFrontmatter, extractTitle, computeContentHash, isBinaryFile, getMimeType } from './frontmatter'
|
|
8
|
+
import { isDbAvailable } from './db-state'
|
|
9
|
+
|
|
10
|
+
// Default frontmatter values for new documents
|
|
11
|
+
const DEFAULT_FRONTMATTER = {
|
|
12
|
+
tags: [] as string[],
|
|
13
|
+
shared: false
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type SyncResult = 'added' | 'updated' | 'restored' | 'unchanged' | 'skipped'
|
|
17
|
+
|
|
18
|
+
export async function syncDocument(absolutePath: string): Promise<SyncResult> {
|
|
19
|
+
if (!isDbAvailable()) return 'skipped'
|
|
20
|
+
|
|
21
|
+
const db = getDb()
|
|
22
|
+
const relativePath = toRelativePath(absolutePath)
|
|
23
|
+
const filename = basename(absolutePath)
|
|
24
|
+
|
|
25
|
+
// Skip hidden files
|
|
26
|
+
if (filename.startsWith('.')) return 'skipped'
|
|
27
|
+
|
|
28
|
+
let fileContent: string
|
|
29
|
+
try {
|
|
30
|
+
fileContent = await readFile(absolutePath, 'utf-8')
|
|
31
|
+
} catch {
|
|
32
|
+
console.error(`[sync] Failed to read file: ${relativePath}`)
|
|
33
|
+
return 'skipped'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const isBinary = isBinaryFile(filename)
|
|
37
|
+
|
|
38
|
+
const [existing] = await db
|
|
39
|
+
.select()
|
|
40
|
+
.from(schema.documents)
|
|
41
|
+
.where(eq(schema.documents.path, relativePath))
|
|
42
|
+
.limit(1)
|
|
43
|
+
|
|
44
|
+
if (isBinary) {
|
|
45
|
+
if (!existing) {
|
|
46
|
+
await db.insert(schema.documents).values({
|
|
47
|
+
path: relativePath,
|
|
48
|
+
title: filename,
|
|
49
|
+
fileType: 'binary',
|
|
50
|
+
mimeType: getMimeType(filename),
|
|
51
|
+
syncedAt: new Date()
|
|
52
|
+
})
|
|
53
|
+
return 'added'
|
|
54
|
+
} else if (existing.deletedAt) {
|
|
55
|
+
// Restore previously deleted binary file
|
|
56
|
+
await db.update(schema.documents).set({
|
|
57
|
+
deletedAt: null,
|
|
58
|
+
syncedAt: new Date()
|
|
59
|
+
}).where(eq(schema.documents.id, existing.id))
|
|
60
|
+
return 'restored'
|
|
61
|
+
}
|
|
62
|
+
return 'unchanged'
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const { metadata: initialMetadata, body: bodyContent } = parseFrontmatter(fileContent)
|
|
66
|
+
let metadata = initialMetadata
|
|
67
|
+
const title = extractTitle(metadata, bodyContent, filename)
|
|
68
|
+
|
|
69
|
+
// For new documents without frontmatter, add defaults
|
|
70
|
+
const needsDefaults = !existing && Object.keys(metadata).length === 0
|
|
71
|
+
|
|
72
|
+
if (needsDefaults) {
|
|
73
|
+
// Merge defaults into metadata
|
|
74
|
+
metadata = { ...DEFAULT_FRONTMATTER }
|
|
75
|
+
|
|
76
|
+
// Write frontmatter back to file
|
|
77
|
+
const newContent = stringifyFrontmatter(metadata, bodyContent)
|
|
78
|
+
try {
|
|
79
|
+
await writeFile(absolutePath, newContent, 'utf-8')
|
|
80
|
+
fileContent = newContent
|
|
81
|
+
} catch (err) {
|
|
82
|
+
console.error(`[sync] Failed to write frontmatter to ${relativePath}:`, err)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const contentHash = computeContentHash(fileContent)
|
|
87
|
+
|
|
88
|
+
if (existing) {
|
|
89
|
+
const contentChanged = existing.contentHash !== contentHash
|
|
90
|
+
const wasDeleted = existing.deletedAt !== null
|
|
91
|
+
|
|
92
|
+
// Update if content changed OR document was previously deleted (restore it)
|
|
93
|
+
if (contentChanged || wasDeleted) {
|
|
94
|
+
await db.update(schema.documents).set({
|
|
95
|
+
title,
|
|
96
|
+
content: bodyContent,
|
|
97
|
+
contentHash,
|
|
98
|
+
tags: Array.isArray(metadata.tags) ? metadata.tags as string[] : existing.tags,
|
|
99
|
+
deletedAt: null, // Clear deletion flag - file exists on disk
|
|
100
|
+
syncedAt: new Date(),
|
|
101
|
+
modifiedAt: new Date()
|
|
102
|
+
}).where(eq(schema.documents.id, existing.id))
|
|
103
|
+
return wasDeleted ? 'restored' : 'updated'
|
|
104
|
+
}
|
|
105
|
+
return 'unchanged'
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
await db.insert(schema.documents).values({
|
|
109
|
+
path: relativePath,
|
|
110
|
+
title,
|
|
111
|
+
content: bodyContent,
|
|
112
|
+
contentHash,
|
|
113
|
+
tags: Array.isArray(metadata.tags) ? metadata.tags as string[] : [],
|
|
114
|
+
shared: metadata.shared === true,
|
|
115
|
+
fileType: 'markdown',
|
|
116
|
+
syncedAt: new Date()
|
|
117
|
+
})
|
|
118
|
+
return 'added'
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function markDocumentDeleted(absolutePath: string): Promise<void> {
|
|
122
|
+
if (!isDbAvailable()) return
|
|
123
|
+
|
|
124
|
+
const db = getDb()
|
|
125
|
+
const relativePath = toRelativePath(absolutePath)
|
|
126
|
+
|
|
127
|
+
await db.update(schema.documents).set({
|
|
128
|
+
deletedAt: new Date(),
|
|
129
|
+
modifiedAt: new Date()
|
|
130
|
+
}).where(eq(schema.documents.path, relativePath))
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function handleDocumentMove(oldPath: string, newPath: string): Promise<void> {
|
|
134
|
+
if (!isDbAvailable()) return
|
|
135
|
+
|
|
136
|
+
const db = getDb()
|
|
137
|
+
const oldRelative = toRelativePath(oldPath)
|
|
138
|
+
const newRelative = toRelativePath(newPath)
|
|
139
|
+
|
|
140
|
+
await db.update(schema.documents).set({
|
|
141
|
+
path: newRelative,
|
|
142
|
+
modifiedAt: new Date()
|
|
143
|
+
}).where(eq(schema.documents.path, oldRelative))
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function fullSync(): Promise<{ added: number, updated: number, removed: number }> {
|
|
147
|
+
if (!isDbAvailable()) {
|
|
148
|
+
console.log('[sync] Database not available for full sync')
|
|
149
|
+
return { added: 0, updated: 0, removed: 0 }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const db = getDb()
|
|
153
|
+
const vaultRoot = getVaultRoot()
|
|
154
|
+
const stats = { added: 0, updated: 0, removed: 0 }
|
|
155
|
+
const foundPaths = new Set<string>()
|
|
156
|
+
|
|
157
|
+
async function scanDir(dir: string): Promise<void> {
|
|
158
|
+
let entries
|
|
159
|
+
try {
|
|
160
|
+
entries = await readdir(dir, { withFileTypes: true })
|
|
161
|
+
} catch (err) {
|
|
162
|
+
console.error(`[sync] Failed to read directory ${dir}:`, err)
|
|
163
|
+
return
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
for (const entry of entries) {
|
|
167
|
+
if (entry.name.startsWith('.')) continue
|
|
168
|
+
|
|
169
|
+
const fullPath = join(dir, entry.name)
|
|
170
|
+
|
|
171
|
+
if (entry.isDirectory()) {
|
|
172
|
+
await scanDir(fullPath)
|
|
173
|
+
} else if (entry.isFile()) {
|
|
174
|
+
const relativePath = toRelativePath(fullPath)
|
|
175
|
+
foundPaths.add(relativePath)
|
|
176
|
+
try {
|
|
177
|
+
const result = await syncDocument(fullPath)
|
|
178
|
+
if (result === 'added') stats.added++
|
|
179
|
+
else if (result === 'updated' || result === 'restored') stats.updated++
|
|
180
|
+
} catch (err) {
|
|
181
|
+
console.error(`[sync] Failed to sync ${relativePath}:`, err)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
console.log(`[sync] Starting full sync of ${vaultRoot}`)
|
|
188
|
+
await scanDir(vaultRoot)
|
|
189
|
+
console.log(`[sync] Scanned ${foundPaths.size} files`)
|
|
190
|
+
|
|
191
|
+
// Mark documents not found on disk as deleted
|
|
192
|
+
const dbDocs = await db
|
|
193
|
+
.select({ id: schema.documents.id, path: schema.documents.path })
|
|
194
|
+
.from(schema.documents)
|
|
195
|
+
.where(isNull(schema.documents.deletedAt))
|
|
196
|
+
|
|
197
|
+
for (const doc of dbDocs) {
|
|
198
|
+
if (!foundPaths.has(doc.path)) {
|
|
199
|
+
await db.update(schema.documents).set({
|
|
200
|
+
deletedAt: new Date()
|
|
201
|
+
}).where(eq(schema.documents.id, doc.id))
|
|
202
|
+
stats.removed++
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return stats
|
|
207
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import matter from 'gray-matter'
|
|
2
|
+
import { createHash } from 'crypto'
|
|
3
|
+
|
|
4
|
+
export interface ParsedDocument {
|
|
5
|
+
metadata: Record<string, unknown>
|
|
6
|
+
body: string
|
|
7
|
+
raw: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function parseFrontmatter(content: string): ParsedDocument {
|
|
11
|
+
const { data, content: body } = matter(content)
|
|
12
|
+
return {
|
|
13
|
+
metadata: data,
|
|
14
|
+
body,
|
|
15
|
+
raw: content
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function stringifyFrontmatter(metadata: Record<string, unknown>, body: string): string {
|
|
20
|
+
if (Object.keys(metadata).length === 0)
|
|
21
|
+
return body
|
|
22
|
+
|
|
23
|
+
return matter.stringify(body, metadata)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function computeContentHash(content: string): string {
|
|
27
|
+
return createHash('sha256').update(content).digest('hex')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function extractTitle(metadata: Record<string, unknown>, body: string, filename: string): string {
|
|
31
|
+
if (metadata.title && typeof metadata.title === 'string')
|
|
32
|
+
return metadata.title
|
|
33
|
+
|
|
34
|
+
const h1Match = body.match(/^#\s+(.+)$/m)
|
|
35
|
+
if (h1Match?.[1])
|
|
36
|
+
return h1Match[1].trim()
|
|
37
|
+
|
|
38
|
+
return filename.replace(/\.[^.]+$/, '')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const binaryExtensions = new Set([
|
|
42
|
+
'png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'ico', 'bmp', 'tiff',
|
|
43
|
+
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx',
|
|
44
|
+
'zip', 'tar', 'gz', 'rar', '7z',
|
|
45
|
+
'mp3', 'mp4', 'wav', 'avi', 'mov', 'mkv', 'flac',
|
|
46
|
+
'ttf', 'otf', 'woff', 'woff2', 'eot'
|
|
47
|
+
])
|
|
48
|
+
|
|
49
|
+
export function isBinaryFile(filename: string): boolean {
|
|
50
|
+
const ext = filename.split('.').pop()?.toLowerCase() || ''
|
|
51
|
+
return binaryExtensions.has(ext)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function getFileType(filename: string): 'markdown' | 'text' | 'binary' {
|
|
55
|
+
if (isBinaryFile(filename)) return 'binary'
|
|
56
|
+
const ext = filename.split('.').pop()?.toLowerCase() || ''
|
|
57
|
+
if (ext === 'md' || ext === 'mdx') return 'markdown'
|
|
58
|
+
return 'text'
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const mimeTypes: Record<string, string> = {
|
|
62
|
+
png: 'image/png',
|
|
63
|
+
jpg: 'image/jpeg',
|
|
64
|
+
jpeg: 'image/jpeg',
|
|
65
|
+
gif: 'image/gif',
|
|
66
|
+
webp: 'image/webp',
|
|
67
|
+
svg: 'image/svg+xml',
|
|
68
|
+
ico: 'image/x-icon',
|
|
69
|
+
bmp: 'image/bmp',
|
|
70
|
+
pdf: 'application/pdf',
|
|
71
|
+
doc: 'application/msword',
|
|
72
|
+
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
73
|
+
xls: 'application/vnd.ms-excel',
|
|
74
|
+
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
75
|
+
zip: 'application/zip',
|
|
76
|
+
mp3: 'audio/mpeg',
|
|
77
|
+
mp4: 'video/mp4',
|
|
78
|
+
wav: 'audio/wav'
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function getMimeType(filename: string): string | undefined {
|
|
82
|
+
const ext = filename.split('.').pop()?.toLowerCase()
|
|
83
|
+
return ext ? mimeTypes[ext] : undefined
|
|
84
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { EventEmitter } from 'events'
|
|
2
|
+
import type { NotificationPayload } from '~~/shared/types'
|
|
3
|
+
|
|
4
|
+
type Peer = {
|
|
5
|
+
id: string
|
|
6
|
+
send: (data: string) => void
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
class NotificationBus extends EventEmitter {
|
|
10
|
+
private peers = new Map<string, Peer>()
|
|
11
|
+
|
|
12
|
+
registerPeer(peer: Peer) {
|
|
13
|
+
this.peers.set(peer.id, peer)
|
|
14
|
+
console.log(`[notification-bus] Peer connected: ${peer.id} (${this.peers.size} total)`)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
unregisterPeer(peerId: string) {
|
|
18
|
+
this.peers.delete(peerId)
|
|
19
|
+
console.log(`[notification-bus] Peer disconnected: ${peerId} (${this.peers.size} total)`)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
broadcast(payload: NotificationPayload) {
|
|
23
|
+
const message = JSON.stringify({
|
|
24
|
+
...payload,
|
|
25
|
+
timestamp: payload.timestamp || new Date().toISOString()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
for (const [peerId, peer] of this.peers) {
|
|
29
|
+
try {
|
|
30
|
+
peer.send(message)
|
|
31
|
+
} catch {
|
|
32
|
+
// Peer may have disconnected, remove it
|
|
33
|
+
this.unregisterPeer(peerId)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
console.log(`[notification-bus] Broadcast: ${payload.type} to ${this.peers.size} peers`)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
sendToPeer(peerId: string, payload: NotificationPayload) {
|
|
41
|
+
const peer = this.peers.get(peerId)
|
|
42
|
+
if (peer) {
|
|
43
|
+
try {
|
|
44
|
+
peer.send(JSON.stringify({
|
|
45
|
+
...payload,
|
|
46
|
+
timestamp: payload.timestamp || new Date().toISOString()
|
|
47
|
+
}))
|
|
48
|
+
} catch {
|
|
49
|
+
this.unregisterPeer(peerId)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
getPeerCount(): number {
|
|
55
|
+
return this.peers.size
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Singleton instance
|
|
60
|
+
export const notificationBus = new NotificationBus()
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { resolve, relative, join } from 'path'
|
|
2
|
+
import { existsSync } from 'fs'
|
|
3
|
+
|
|
4
|
+
function getDefaultVaultPath(): string {
|
|
5
|
+
// Check common locations in order of preference
|
|
6
|
+
const candidates = [
|
|
7
|
+
process.env.VAULT_PATH,
|
|
8
|
+
'/tmp/cognova-vault',
|
|
9
|
+
process.env.HOME ? join(process.env.HOME, 'Documents', 'vault') : null,
|
|
10
|
+
process.cwd()
|
|
11
|
+
].filter(Boolean) as string[]
|
|
12
|
+
|
|
13
|
+
for (const path of candidates) {
|
|
14
|
+
// Always resolve to absolute path
|
|
15
|
+
const absolutePath = resolve(path)
|
|
16
|
+
if (existsSync(absolutePath)) {
|
|
17
|
+
return absolutePath
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Fallback to current working directory
|
|
22
|
+
return process.cwd()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const VAULT_ROOT = getDefaultVaultPath()
|
|
26
|
+
|
|
27
|
+
export function getVaultRoot(): string {
|
|
28
|
+
return VAULT_ROOT
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function validatePath(requestedPath: string): string {
|
|
32
|
+
// Normalize the path - remove leading slash and resolve
|
|
33
|
+
const normalizedPath = requestedPath.replace(/^\/+/, '')
|
|
34
|
+
const resolved = resolve(VAULT_ROOT, normalizedPath)
|
|
35
|
+
const rel = relative(VAULT_ROOT, resolved)
|
|
36
|
+
|
|
37
|
+
// Prevent traversal outside vault
|
|
38
|
+
if (rel.startsWith('..') || !resolved.startsWith(VAULT_ROOT)) {
|
|
39
|
+
throw createError({
|
|
40
|
+
statusCode: 403,
|
|
41
|
+
message: 'Path outside vault directory'
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return resolved
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function toRelativePath(absolutePath: string): string {
|
|
49
|
+
const rel = relative(VAULT_ROOT, absolutePath)
|
|
50
|
+
return '/' + rel
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function joinVaultPath(...paths: string[]): string {
|
|
54
|
+
return join(VAULT_ROOT, ...paths)
|
|
55
|
+
}
|