@twelvehart/supermemory-runtime 1.0.0-next.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 +57 -0
- package/README.md +374 -0
- package/dist/index.js +189 -0
- package/dist/mcp/index.js +1132 -0
- package/docker-compose.prod.yml +91 -0
- package/docker-compose.yml +358 -0
- package/drizzle/0000_dapper_the_professor.sql +159 -0
- package/drizzle/0001_api_keys.sql +51 -0
- package/drizzle/meta/0000_snapshot.json +1532 -0
- package/drizzle/meta/_journal.json +13 -0
- package/drizzle.config.ts +20 -0
- package/package.json +114 -0
- package/scripts/add-extraction-job.ts +122 -0
- package/scripts/benchmark-pgvector.ts +122 -0
- package/scripts/bootstrap.sh +209 -0
- package/scripts/check-runtime-pack.ts +111 -0
- package/scripts/claude-mcp-config.ts +336 -0
- package/scripts/docker-entrypoint.sh +183 -0
- package/scripts/doctor.ts +377 -0
- package/scripts/init-db.sql +33 -0
- package/scripts/install.sh +1110 -0
- package/scripts/mcp-setup.ts +271 -0
- package/scripts/migrations/001_create_pgvector_extension.sql +31 -0
- package/scripts/migrations/002_create_memory_embeddings_table.sql +75 -0
- package/scripts/migrations/003_create_hnsw_index.sql +94 -0
- package/scripts/migrations/004_create_memory_embeddings_standalone.sql +70 -0
- package/scripts/migrations/005_create_chunks_table.sql +95 -0
- package/scripts/migrations/006_create_processing_queue.sql +45 -0
- package/scripts/migrations/generate_test_data.sql +42 -0
- package/scripts/migrations/phase1_comprehensive_test.sql +204 -0
- package/scripts/migrations/run_migrations.sh +286 -0
- package/scripts/migrations/test_hnsw_index.sql +255 -0
- package/scripts/pre-commit-secrets +282 -0
- package/scripts/run-extraction-worker.ts +46 -0
- package/scripts/run-phase1-tests.sh +291 -0
- package/scripts/setup.ts +222 -0
- package/scripts/smoke-install.sh +12 -0
- package/scripts/test-health-endpoint.sh +328 -0
- package/src/api/index.ts +2 -0
- package/src/api/middleware/auth.ts +80 -0
- package/src/api/middleware/csrf.ts +308 -0
- package/src/api/middleware/errorHandler.ts +166 -0
- package/src/api/middleware/rateLimit.ts +360 -0
- package/src/api/middleware/validation.ts +514 -0
- package/src/api/routes/documents.ts +286 -0
- package/src/api/routes/profiles.ts +237 -0
- package/src/api/routes/search.ts +71 -0
- package/src/api/stores/index.ts +58 -0
- package/src/config/bootstrap-env.ts +3 -0
- package/src/config/env.ts +71 -0
- package/src/config/feature-flags.ts +25 -0
- package/src/config/index.ts +140 -0
- package/src/config/secrets.config.ts +291 -0
- package/src/db/client.ts +92 -0
- package/src/db/index.ts +73 -0
- package/src/db/postgres.ts +72 -0
- package/src/db/schema/chunks.schema.ts +31 -0
- package/src/db/schema/containers.schema.ts +46 -0
- package/src/db/schema/documents.schema.ts +49 -0
- package/src/db/schema/embeddings.schema.ts +32 -0
- package/src/db/schema/index.ts +11 -0
- package/src/db/schema/memories.schema.ts +72 -0
- package/src/db/schema/profiles.schema.ts +34 -0
- package/src/db/schema/queue.schema.ts +59 -0
- package/src/db/schema/relationships.schema.ts +42 -0
- package/src/db/schema.ts +223 -0
- package/src/db/worker-connection.ts +47 -0
- package/src/index.ts +235 -0
- package/src/mcp/CLAUDE.md +1 -0
- package/src/mcp/index.ts +1380 -0
- package/src/mcp/legacyState.ts +22 -0
- package/src/mcp/rateLimit.ts +358 -0
- package/src/mcp/resources.ts +309 -0
- package/src/mcp/results.ts +104 -0
- package/src/mcp/tools.ts +401 -0
- package/src/queues/config.ts +119 -0
- package/src/queues/index.ts +289 -0
- package/src/sdk/client.ts +225 -0
- package/src/sdk/errors.ts +266 -0
- package/src/sdk/http.ts +560 -0
- package/src/sdk/index.ts +244 -0
- package/src/sdk/resources/base.ts +65 -0
- package/src/sdk/resources/connections.ts +204 -0
- package/src/sdk/resources/documents.ts +163 -0
- package/src/sdk/resources/index.ts +10 -0
- package/src/sdk/resources/memories.ts +150 -0
- package/src/sdk/resources/search.ts +60 -0
- package/src/sdk/resources/settings.ts +36 -0
- package/src/sdk/types.ts +674 -0
- package/src/services/chunking/index.ts +451 -0
- package/src/services/chunking.service.ts +650 -0
- package/src/services/csrf.service.ts +252 -0
- package/src/services/documents.repository.ts +219 -0
- package/src/services/documents.service.ts +191 -0
- package/src/services/embedding.service.ts +404 -0
- package/src/services/extraction.service.ts +300 -0
- package/src/services/extractors/code.extractor.ts +451 -0
- package/src/services/extractors/index.ts +9 -0
- package/src/services/extractors/markdown.extractor.ts +461 -0
- package/src/services/extractors/pdf.extractor.ts +315 -0
- package/src/services/extractors/text.extractor.ts +118 -0
- package/src/services/extractors/url.extractor.ts +243 -0
- package/src/services/index.ts +235 -0
- package/src/services/ingestion.service.ts +177 -0
- package/src/services/llm/anthropic.ts +400 -0
- package/src/services/llm/base.ts +460 -0
- package/src/services/llm/contradiction-detector.service.ts +526 -0
- package/src/services/llm/heuristics.ts +148 -0
- package/src/services/llm/index.ts +309 -0
- package/src/services/llm/memory-classifier.service.ts +383 -0
- package/src/services/llm/memory-extension-detector.service.ts +523 -0
- package/src/services/llm/mock.ts +470 -0
- package/src/services/llm/openai.ts +398 -0
- package/src/services/llm/prompts.ts +438 -0
- package/src/services/llm/types.ts +373 -0
- package/src/services/memory.repository.ts +1769 -0
- package/src/services/memory.service.ts +1338 -0
- package/src/services/memory.types.ts +234 -0
- package/src/services/persistence/index.ts +295 -0
- package/src/services/pipeline.service.ts +509 -0
- package/src/services/profile.repository.ts +436 -0
- package/src/services/profile.service.ts +560 -0
- package/src/services/profile.types.ts +270 -0
- package/src/services/relationships/detector.ts +1128 -0
- package/src/services/relationships/index.ts +268 -0
- package/src/services/relationships/memory-integration.ts +459 -0
- package/src/services/relationships/strategies.ts +132 -0
- package/src/services/relationships/types.ts +370 -0
- package/src/services/search.service.ts +761 -0
- package/src/services/search.types.ts +220 -0
- package/src/services/secrets.service.ts +384 -0
- package/src/services/vectorstore/base.ts +327 -0
- package/src/services/vectorstore/index.ts +444 -0
- package/src/services/vectorstore/memory.ts +286 -0
- package/src/services/vectorstore/migration.ts +295 -0
- package/src/services/vectorstore/mock.ts +403 -0
- package/src/services/vectorstore/pgvector.ts +695 -0
- package/src/services/vectorstore/types.ts +247 -0
- package/src/startup.ts +389 -0
- package/src/types/api.types.ts +193 -0
- package/src/types/document.types.ts +103 -0
- package/src/types/index.ts +241 -0
- package/src/types/profile.base.ts +133 -0
- package/src/utils/errors.ts +447 -0
- package/src/utils/id.ts +15 -0
- package/src/utils/index.ts +101 -0
- package/src/utils/logger.ts +313 -0
- package/src/utils/sanitization.ts +501 -0
- package/src/utils/secret-validation.ts +273 -0
- package/src/utils/synonyms.ts +188 -0
- package/src/utils/validation.ts +581 -0
- package/src/workers/chunking.worker.ts +242 -0
- package/src/workers/embedding.worker.ts +358 -0
- package/src/workers/extraction.worker.ts +346 -0
- package/src/workers/indexing.worker.ts +505 -0
- package/tsconfig.json +38 -0
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { Hono } from 'hono'
|
|
2
|
+
import { v4 as uuidv4 } from 'uuid'
|
|
3
|
+
import {
|
|
4
|
+
CreateDocumentSchema,
|
|
5
|
+
UpdateDocumentSchema,
|
|
6
|
+
ListDocumentsQuerySchema,
|
|
7
|
+
BulkDeleteSchema,
|
|
8
|
+
ApiDocument,
|
|
9
|
+
SuccessResponse,
|
|
10
|
+
} from '../../types/api.types.js'
|
|
11
|
+
import { requireScopes } from '../middleware/auth.js'
|
|
12
|
+
import { notFound, validationError } from '../middleware/errorHandler.js'
|
|
13
|
+
import { uploadRateLimit } from '../middleware/rateLimit.js'
|
|
14
|
+
import { getDocumentService } from '../../services/documents.service.js'
|
|
15
|
+
import { enqueueDocumentForProcessing } from '../../services/ingestion.service.js'
|
|
16
|
+
import { getLogger } from '../../utils/logger.js'
|
|
17
|
+
|
|
18
|
+
const documentsRouter = new Hono()
|
|
19
|
+
const documentsService = getDocumentService()
|
|
20
|
+
const logger = getLogger('documents-route')
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* POST / - Add a new document
|
|
24
|
+
*/
|
|
25
|
+
documentsRouter.post('/', requireScopes('write'), async (c) => {
|
|
26
|
+
const startTime = Date.now()
|
|
27
|
+
|
|
28
|
+
const body = await c.req.json()
|
|
29
|
+
const validatedData = CreateDocumentSchema.parse(body)
|
|
30
|
+
|
|
31
|
+
// Check for duplicate customId
|
|
32
|
+
if (validatedData.customId) {
|
|
33
|
+
const existing = await documentsService.getDocumentByCustomId(validatedData.customId)
|
|
34
|
+
if (existing) {
|
|
35
|
+
validationError(`Document with customId '${validatedData.customId}' already exists`)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const created = await documentsService.createDocument({
|
|
40
|
+
id: uuidv4(),
|
|
41
|
+
content: validatedData.content,
|
|
42
|
+
containerTag: validatedData.containerTag,
|
|
43
|
+
metadata: validatedData.metadata,
|
|
44
|
+
customId: validatedData.customId,
|
|
45
|
+
contentType: 'text/plain',
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
const ingestion = await enqueueDocumentForProcessing({
|
|
49
|
+
documentId: created.id,
|
|
50
|
+
content: created.content,
|
|
51
|
+
containerTag: created.containerTag ?? 'default',
|
|
52
|
+
sourceType: 'text',
|
|
53
|
+
})
|
|
54
|
+
if (ingestion.error) {
|
|
55
|
+
logger.warn('Document ingestion reported error', {
|
|
56
|
+
documentId: created.id,
|
|
57
|
+
mode: ingestion.mode,
|
|
58
|
+
error: ingestion.error,
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const response: SuccessResponse<ApiDocument> = {
|
|
63
|
+
data: created,
|
|
64
|
+
timing: Date.now() - startTime,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return c.json(response, 201)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* GET /:id - Get a document by ID
|
|
72
|
+
*/
|
|
73
|
+
documentsRouter.get('/:id', requireScopes('read'), async (c) => {
|
|
74
|
+
const startTime = Date.now()
|
|
75
|
+
const id = c.req.param('id')
|
|
76
|
+
|
|
77
|
+
const foundDocument = await documentsService.getDocument(id)
|
|
78
|
+
|
|
79
|
+
if (!foundDocument) {
|
|
80
|
+
return notFound('Document', id)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const response: SuccessResponse<ApiDocument> = {
|
|
84
|
+
data: foundDocument,
|
|
85
|
+
timing: Date.now() - startTime,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return c.json(response)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* PUT /:id - Update a document
|
|
93
|
+
*/
|
|
94
|
+
documentsRouter.put('/:id', requireScopes('write'), async (c) => {
|
|
95
|
+
const startTime = Date.now()
|
|
96
|
+
const id = c.req.param('id')
|
|
97
|
+
|
|
98
|
+
const body = await c.req.json()
|
|
99
|
+
const validatedData = UpdateDocumentSchema.parse(body)
|
|
100
|
+
|
|
101
|
+
const updatedDocument = await documentsService.updateDocument(id, {
|
|
102
|
+
content: validatedData.content,
|
|
103
|
+
containerTag: validatedData.containerTag,
|
|
104
|
+
metadata: validatedData.metadata,
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
if (!updatedDocument) {
|
|
108
|
+
return notFound('Document', id)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (validatedData.content) {
|
|
112
|
+
const ingestion = await enqueueDocumentForProcessing({
|
|
113
|
+
documentId: updatedDocument.id,
|
|
114
|
+
content: updatedDocument.content,
|
|
115
|
+
containerTag: updatedDocument.containerTag ?? 'default',
|
|
116
|
+
sourceType: 'text',
|
|
117
|
+
})
|
|
118
|
+
if (ingestion.error) {
|
|
119
|
+
logger.warn('Document re-ingestion reported error', {
|
|
120
|
+
documentId: updatedDocument.id,
|
|
121
|
+
mode: ingestion.mode,
|
|
122
|
+
error: ingestion.error,
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const response: SuccessResponse<ApiDocument> = {
|
|
128
|
+
data: updatedDocument,
|
|
129
|
+
timing: Date.now() - startTime,
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return c.json(response)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* DELETE /:id - Delete a document
|
|
137
|
+
*/
|
|
138
|
+
documentsRouter.delete('/:id', requireScopes('write'), async (c) => {
|
|
139
|
+
const startTime = Date.now()
|
|
140
|
+
const id = c.req.param('id')
|
|
141
|
+
|
|
142
|
+
const deletedId = await documentsService.deleteDocument(id)
|
|
143
|
+
|
|
144
|
+
if (!deletedId) {
|
|
145
|
+
return notFound('Document', id)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const response: SuccessResponse<{ deleted: true; id: string }> = {
|
|
149
|
+
data: { deleted: true, id: deletedId },
|
|
150
|
+
timing: Date.now() - startTime,
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return c.json(response)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* GET / - List documents with optional filtering
|
|
158
|
+
*/
|
|
159
|
+
documentsRouter.get('/', requireScopes('read'), async (c) => {
|
|
160
|
+
const startTime = Date.now()
|
|
161
|
+
|
|
162
|
+
const query = ListDocumentsQuerySchema.parse({
|
|
163
|
+
containerTag: c.req.query('containerTag'),
|
|
164
|
+
limit: c.req.query('limit'),
|
|
165
|
+
offset: c.req.query('offset'),
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
const { documents: results, total } = await documentsService.listDocuments({
|
|
169
|
+
containerTag: query.containerTag,
|
|
170
|
+
limit: query.limit,
|
|
171
|
+
offset: query.offset,
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
const response: SuccessResponse<{
|
|
175
|
+
documents: ApiDocument[]
|
|
176
|
+
total: number
|
|
177
|
+
limit: number
|
|
178
|
+
offset: number
|
|
179
|
+
}> = {
|
|
180
|
+
data: {
|
|
181
|
+
documents: results,
|
|
182
|
+
total,
|
|
183
|
+
limit: query.limit,
|
|
184
|
+
offset: query.offset,
|
|
185
|
+
},
|
|
186
|
+
timing: Date.now() - startTime,
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return c.json(response)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* POST /file - Upload a file as a document
|
|
194
|
+
*/
|
|
195
|
+
documentsRouter.post('/file', requireScopes('write'), uploadRateLimit, async (c) => {
|
|
196
|
+
const startTime = Date.now()
|
|
197
|
+
|
|
198
|
+
const formData = await c.req.formData()
|
|
199
|
+
const file = formData.get('file')
|
|
200
|
+
const containerTag = formData.get('containerTag') as string | null
|
|
201
|
+
const metadataStr = formData.get('metadata') as string | null
|
|
202
|
+
|
|
203
|
+
if (!file || !(file instanceof File)) {
|
|
204
|
+
validationError('File is required')
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Parse metadata if provided
|
|
208
|
+
let metadata: Record<string, unknown> | undefined
|
|
209
|
+
if (metadataStr) {
|
|
210
|
+
try {
|
|
211
|
+
metadata = JSON.parse(metadataStr)
|
|
212
|
+
} catch {
|
|
213
|
+
validationError('Invalid metadata JSON')
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Read file content
|
|
218
|
+
const content = await file.text()
|
|
219
|
+
|
|
220
|
+
if (!content.trim()) {
|
|
221
|
+
validationError('File content is empty')
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const newDocument = await documentsService.createDocument({
|
|
225
|
+
id: uuidv4(),
|
|
226
|
+
content,
|
|
227
|
+
containerTag: containerTag || undefined,
|
|
228
|
+
metadata: {
|
|
229
|
+
...metadata,
|
|
230
|
+
filename: file.name,
|
|
231
|
+
fileType: file.type,
|
|
232
|
+
fileSize: file.size,
|
|
233
|
+
},
|
|
234
|
+
contentType: file.type || 'text/plain',
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
const ingestion = await enqueueDocumentForProcessing({
|
|
238
|
+
documentId: newDocument.id,
|
|
239
|
+
content: newDocument.content,
|
|
240
|
+
containerTag: newDocument.containerTag ?? 'default',
|
|
241
|
+
sourceType: 'file',
|
|
242
|
+
})
|
|
243
|
+
if (ingestion.error) {
|
|
244
|
+
logger.warn('File ingestion reported error', {
|
|
245
|
+
documentId: newDocument.id,
|
|
246
|
+
mode: ingestion.mode,
|
|
247
|
+
error: ingestion.error,
|
|
248
|
+
})
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const responseTime = Date.now()
|
|
252
|
+
const response: SuccessResponse<ApiDocument> = {
|
|
253
|
+
data: newDocument,
|
|
254
|
+
timing: responseTime - startTime,
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return c.json(response, 201)
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* POST /bulk-delete - Delete multiple documents
|
|
262
|
+
*/
|
|
263
|
+
documentsRouter.post('/bulk-delete', requireScopes('write'), async (c) => {
|
|
264
|
+
const startTime = Date.now()
|
|
265
|
+
|
|
266
|
+
const body = await c.req.json()
|
|
267
|
+
const validatedData = BulkDeleteSchema.parse(body)
|
|
268
|
+
|
|
269
|
+
const { deletedIds, notFoundIds } = await documentsService.bulkDelete({
|
|
270
|
+
ids: validatedData.ids,
|
|
271
|
+
containerTags: validatedData.containerTags,
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
const response: SuccessResponse<{ deleted: string[]; notFound: string[]; count: number }> = {
|
|
275
|
+
data: {
|
|
276
|
+
deleted: deletedIds,
|
|
277
|
+
notFound: notFoundIds,
|
|
278
|
+
count: deletedIds.length,
|
|
279
|
+
},
|
|
280
|
+
timing: Date.now() - startTime,
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return c.json(response)
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
export { documentsRouter }
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { Hono } from 'hono'
|
|
2
|
+
import { UpdateProfileSchema, ApiProfile, SuccessResponse } from '../../types/api.types.js'
|
|
3
|
+
import { requireScopes } from '../middleware/auth.js'
|
|
4
|
+
import { notFound } from '../middleware/errorHandler.js'
|
|
5
|
+
import { getDatabaseUrl } from '../../db/client.js'
|
|
6
|
+
import { getPostgresDatabase } from '../../db/postgres.js'
|
|
7
|
+
import { containerTags } from '../../db/schema/containers.schema.js'
|
|
8
|
+
import { documents } from '../../db/schema/documents.schema.js'
|
|
9
|
+
import { desc, eq, inArray, sql } from 'drizzle-orm'
|
|
10
|
+
|
|
11
|
+
const profilesRouter = new Hono()
|
|
12
|
+
const db = getPostgresDatabase(getDatabaseUrl())
|
|
13
|
+
|
|
14
|
+
type ContainerTagRow = typeof containerTags.$inferSelect
|
|
15
|
+
|
|
16
|
+
function normalizeSettings(value: unknown): Record<string, unknown> | undefined {
|
|
17
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
18
|
+
return { ...(value as Record<string, unknown>) }
|
|
19
|
+
}
|
|
20
|
+
return undefined
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function toApiProfile(row: ContainerTagRow, documentCount: number): ApiProfile {
|
|
24
|
+
return {
|
|
25
|
+
containerTag: row.tag,
|
|
26
|
+
name: row.displayName ?? undefined,
|
|
27
|
+
description: row.description ?? undefined,
|
|
28
|
+
settings: normalizeSettings(row.settings) ?? undefined,
|
|
29
|
+
documentCount,
|
|
30
|
+
createdAt: row.createdAt.toISOString(),
|
|
31
|
+
updatedAt: row.updatedAt.toISOString(),
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function ensureContainerTag(tag: string): Promise<ContainerTagRow | null> {
|
|
36
|
+
await db.insert(containerTags).values({ tag }).onConflictDoNothing({ target: containerTags.tag })
|
|
37
|
+
const [row] = await db.select().from(containerTags).where(eq(containerTags.tag, tag)).limit(1)
|
|
38
|
+
return row ?? null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function getDocumentCount(containerTag: string): Promise<number> {
|
|
42
|
+
const [countRow] = await db
|
|
43
|
+
.select({ count: sql<number>`count(*)` })
|
|
44
|
+
.from(documents)
|
|
45
|
+
.where(eq(documents.containerTag, containerTag))
|
|
46
|
+
return Number(countRow?.count ?? 0)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* GET /:containerTag - Get profile by container tag
|
|
51
|
+
* If the profile doesn't exist, creates one based on documents with that tag
|
|
52
|
+
*/
|
|
53
|
+
profilesRouter.get('/:containerTag', requireScopes('read'), async (c) => {
|
|
54
|
+
const startTime = Date.now()
|
|
55
|
+
const containerTag = c.req.param('containerTag')
|
|
56
|
+
|
|
57
|
+
const [existingRow] = await db.select().from(containerTags).where(eq(containerTags.tag, containerTag)).limit(1)
|
|
58
|
+
|
|
59
|
+
const documentCount = await getDocumentCount(containerTag)
|
|
60
|
+
|
|
61
|
+
let row = existingRow ?? null
|
|
62
|
+
if (!row) {
|
|
63
|
+
if (documentCount === 0) {
|
|
64
|
+
return notFound('Profile', containerTag)
|
|
65
|
+
}
|
|
66
|
+
row = await ensureContainerTag(containerTag)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (!row) {
|
|
70
|
+
return notFound('Profile', containerTag)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const response: SuccessResponse<ApiProfile> = {
|
|
74
|
+
data: toApiProfile(row, documentCount),
|
|
75
|
+
timing: Date.now() - startTime,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return c.json(response)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* PUT /:containerTag - Update or create a profile
|
|
83
|
+
*/
|
|
84
|
+
profilesRouter.put('/:containerTag', requireScopes('write'), async (c) => {
|
|
85
|
+
const startTime = Date.now()
|
|
86
|
+
const containerTag = c.req.param('containerTag')
|
|
87
|
+
|
|
88
|
+
const body = await c.req.json()
|
|
89
|
+
const validatedData = UpdateProfileSchema.parse(body)
|
|
90
|
+
|
|
91
|
+
const [existingRow] = await db.select().from(containerTags).where(eq(containerTags.tag, containerTag)).limit(1)
|
|
92
|
+
|
|
93
|
+
let row: ContainerTagRow | null = existingRow ?? null
|
|
94
|
+
const now = new Date()
|
|
95
|
+
|
|
96
|
+
if (!row) {
|
|
97
|
+
const [created] = await db
|
|
98
|
+
.insert(containerTags)
|
|
99
|
+
.values({
|
|
100
|
+
tag: containerTag,
|
|
101
|
+
displayName: validatedData.name,
|
|
102
|
+
description: validatedData.description,
|
|
103
|
+
settings: validatedData.settings ?? {},
|
|
104
|
+
createdAt: now,
|
|
105
|
+
updatedAt: now,
|
|
106
|
+
})
|
|
107
|
+
.returning()
|
|
108
|
+
row = created ?? null
|
|
109
|
+
} else {
|
|
110
|
+
const updatePayload: Partial<typeof containerTags.$inferInsert> = {
|
|
111
|
+
updatedAt: now,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (validatedData.name !== undefined) {
|
|
115
|
+
updatePayload.displayName = validatedData.name
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (validatedData.description !== undefined) {
|
|
119
|
+
updatePayload.description = validatedData.description
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (validatedData.settings !== undefined) {
|
|
123
|
+
updatePayload.settings = validatedData.settings
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const [updated] = await db
|
|
127
|
+
.update(containerTags)
|
|
128
|
+
.set(updatePayload)
|
|
129
|
+
.where(eq(containerTags.tag, containerTag))
|
|
130
|
+
.returning()
|
|
131
|
+
row = updated ?? row
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!row) {
|
|
135
|
+
return notFound('Profile', containerTag)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const documentCount = await getDocumentCount(containerTag)
|
|
139
|
+
|
|
140
|
+
const response: SuccessResponse<ApiProfile> = {
|
|
141
|
+
data: toApiProfile(row, documentCount),
|
|
142
|
+
timing: Date.now() - startTime,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return c.json(response)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* GET / - List all profiles
|
|
150
|
+
* Optional query params: limit, offset
|
|
151
|
+
*/
|
|
152
|
+
profilesRouter.get('/', requireScopes('read'), async (c) => {
|
|
153
|
+
const startTime = Date.now()
|
|
154
|
+
|
|
155
|
+
const limitParam = parseInt(c.req.query('limit') || '20', 10)
|
|
156
|
+
const offsetParam = parseInt(c.req.query('offset') || '0', 10)
|
|
157
|
+
|
|
158
|
+
const limit = Math.min(Number.isNaN(limitParam) ? 20 : Math.max(1, limitParam), 100)
|
|
159
|
+
const offset = Number.isNaN(offsetParam) ? 0 : Math.max(0, offsetParam)
|
|
160
|
+
|
|
161
|
+
const documentCounts = await db
|
|
162
|
+
.select({
|
|
163
|
+
tag: documents.containerTag,
|
|
164
|
+
count: sql<number>`count(*)`,
|
|
165
|
+
})
|
|
166
|
+
.from(documents)
|
|
167
|
+
.groupBy(documents.containerTag)
|
|
168
|
+
|
|
169
|
+
const documentCountMap = new Map<string, number>(documentCounts.map((row) => [row.tag, Number(row.count ?? 0)]))
|
|
170
|
+
|
|
171
|
+
const tagRows = await db.select().from(containerTags).orderBy(desc(containerTags.updatedAt))
|
|
172
|
+
const existingTags = new Set(tagRows.map((row) => row.tag))
|
|
173
|
+
const documentTags = documentCounts.map((row) => row.tag).filter(Boolean)
|
|
174
|
+
const missingTags = documentTags.filter((tag) => !existingTags.has(tag))
|
|
175
|
+
|
|
176
|
+
let allRows = tagRows
|
|
177
|
+
if (missingTags.length > 0) {
|
|
178
|
+
await db
|
|
179
|
+
.insert(containerTags)
|
|
180
|
+
.values(missingTags.map((tag) => ({ tag })))
|
|
181
|
+
.onConflictDoNothing({ target: containerTags.tag })
|
|
182
|
+
|
|
183
|
+
const insertedRows = await db.select().from(containerTags).where(inArray(containerTags.tag, missingTags))
|
|
184
|
+
allRows = [...tagRows, ...insertedRows]
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const allProfiles = allRows.map((row) => toApiProfile(row, documentCountMap.get(row.tag) ?? 0))
|
|
188
|
+
|
|
189
|
+
allProfiles.sort((a, b) => b.documentCount - a.documentCount)
|
|
190
|
+
|
|
191
|
+
const total = allProfiles.length
|
|
192
|
+
const paginatedProfiles = allProfiles.slice(offset, offset + limit)
|
|
193
|
+
|
|
194
|
+
const response: SuccessResponse<{
|
|
195
|
+
profiles: ApiProfile[]
|
|
196
|
+
total: number
|
|
197
|
+
limit: number
|
|
198
|
+
offset: number
|
|
199
|
+
}> = {
|
|
200
|
+
data: {
|
|
201
|
+
profiles: paginatedProfiles,
|
|
202
|
+
total,
|
|
203
|
+
limit,
|
|
204
|
+
offset,
|
|
205
|
+
},
|
|
206
|
+
timing: Date.now() - startTime,
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return c.json(response)
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* DELETE /:containerTag - Delete a profile
|
|
214
|
+
* Note: This only deletes the profile metadata, not the documents
|
|
215
|
+
*/
|
|
216
|
+
profilesRouter.delete('/:containerTag', requireScopes('write'), async (c) => {
|
|
217
|
+
const startTime = Date.now()
|
|
218
|
+
const containerTag = c.req.param('containerTag')
|
|
219
|
+
|
|
220
|
+
const deleted = await db
|
|
221
|
+
.delete(containerTags)
|
|
222
|
+
.where(eq(containerTags.tag, containerTag))
|
|
223
|
+
.returning({ tag: containerTags.tag })
|
|
224
|
+
|
|
225
|
+
if (deleted.length === 0) {
|
|
226
|
+
return notFound('Profile', containerTag)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const response: SuccessResponse<{ deleted: true; containerTag: string }> = {
|
|
230
|
+
data: { deleted: true, containerTag },
|
|
231
|
+
timing: Date.now() - startTime,
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return c.json(response)
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
export { profilesRouter }
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { Hono } from 'hono'
|
|
2
|
+
import { SearchRequestSchema, SearchResponse, SearchResult, SuccessResponse } from '../../types/api.types.js'
|
|
3
|
+
import { requireScopes } from '../middleware/auth.js'
|
|
4
|
+
import { searchRateLimit } from '../middleware/rateLimit.js'
|
|
5
|
+
import { getSearchService } from '../../services/search.service.js'
|
|
6
|
+
import type { MetadataFilter } from '../../services/search.types.js'
|
|
7
|
+
|
|
8
|
+
const searchRouter = new Hono()
|
|
9
|
+
const searchService = getSearchService()
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* POST / - Unified search endpoint
|
|
13
|
+
* Supports vector, fulltext, and hybrid search modes
|
|
14
|
+
*/
|
|
15
|
+
searchRouter.post('/', requireScopes('read'), searchRateLimit, async (c) => {
|
|
16
|
+
const startTime = Date.now()
|
|
17
|
+
|
|
18
|
+
const body = await c.req.json()
|
|
19
|
+
const validatedData = SearchRequestSchema.parse(body)
|
|
20
|
+
|
|
21
|
+
const { q, containerTag, searchMode, limit, threshold, rerank, filters } = validatedData
|
|
22
|
+
|
|
23
|
+
const metadataFilters: MetadataFilter[] | undefined = filters?.metadata
|
|
24
|
+
? Object.entries(filters.metadata)
|
|
25
|
+
.filter(([, value]) => typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean')
|
|
26
|
+
.map(([key, value]) => ({
|
|
27
|
+
key,
|
|
28
|
+
value: value as string | number | boolean,
|
|
29
|
+
operator: 'eq',
|
|
30
|
+
}))
|
|
31
|
+
: undefined
|
|
32
|
+
|
|
33
|
+
const dateRange =
|
|
34
|
+
filters?.createdAfter || filters?.createdBefore
|
|
35
|
+
? {
|
|
36
|
+
from: filters.createdAfter ? new Date(filters.createdAfter) : undefined,
|
|
37
|
+
to: filters.createdBefore ? new Date(filters.createdBefore) : undefined,
|
|
38
|
+
}
|
|
39
|
+
: undefined
|
|
40
|
+
|
|
41
|
+
const response = await searchService.hybridSearch(q, containerTag, {
|
|
42
|
+
searchMode,
|
|
43
|
+
limit,
|
|
44
|
+
threshold,
|
|
45
|
+
rerank,
|
|
46
|
+
filters: metadataFilters,
|
|
47
|
+
dateRange,
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
const results: SearchResult[] = response.results.map((result) => ({
|
|
51
|
+
id: result.id,
|
|
52
|
+
content: result.memory?.content ?? result.chunk?.content ?? '',
|
|
53
|
+
score: result.rerankScore ?? result.similarity,
|
|
54
|
+
containerTag: result.memory?.containerTag,
|
|
55
|
+
metadata: result.metadata,
|
|
56
|
+
}))
|
|
57
|
+
|
|
58
|
+
const payload: SuccessResponse<SearchResponse> = {
|
|
59
|
+
data: {
|
|
60
|
+
results,
|
|
61
|
+
total: response.totalCount,
|
|
62
|
+
query: response.query,
|
|
63
|
+
searchMode,
|
|
64
|
+
},
|
|
65
|
+
timing: Date.now() - startTime,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return c.json(payload)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
export { searchRouter }
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared in-memory stores for the API layer.
|
|
3
|
+
* In production, these would be replaced with a database connection.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { ApiDocument, ApiProfile } from '../../types/api.types.js'
|
|
7
|
+
|
|
8
|
+
// Shared document store - single source of truth for all routes
|
|
9
|
+
export const documentsStore = new Map<string, ApiDocument>()
|
|
10
|
+
|
|
11
|
+
// Shared profile store - single source of truth for all routes
|
|
12
|
+
export const profilesStore = new Map<string, ApiProfile>()
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Clear all stores - useful for testing
|
|
16
|
+
*/
|
|
17
|
+
export function clearAllStores(): void {
|
|
18
|
+
documentsStore.clear()
|
|
19
|
+
profilesStore.clear()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Get document count for a container tag
|
|
24
|
+
*/
|
|
25
|
+
export function getDocumentCountByTag(containerTag: string): number {
|
|
26
|
+
let count = 0
|
|
27
|
+
for (const doc of documentsStore.values()) {
|
|
28
|
+
if (doc.containerTag === containerTag) {
|
|
29
|
+
count++
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return count
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Get all documents as array
|
|
37
|
+
*/
|
|
38
|
+
export function getAllDocuments(): ApiDocument[] {
|
|
39
|
+
return Array.from(documentsStore.values())
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Find document by ID or customId
|
|
44
|
+
*/
|
|
45
|
+
export function findDocument(idOrCustomId: string): ApiDocument | undefined {
|
|
46
|
+
// First try by ID
|
|
47
|
+
const doc = documentsStore.get(idOrCustomId)
|
|
48
|
+
if (doc) return doc
|
|
49
|
+
|
|
50
|
+
// Then try by customId
|
|
51
|
+
for (const d of documentsStore.values()) {
|
|
52
|
+
if (d.customId === idOrCustomId) {
|
|
53
|
+
return d
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return undefined
|
|
58
|
+
}
|