@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.
Files changed (156) hide show
  1. package/.env.example +57 -0
  2. package/README.md +374 -0
  3. package/dist/index.js +189 -0
  4. package/dist/mcp/index.js +1132 -0
  5. package/docker-compose.prod.yml +91 -0
  6. package/docker-compose.yml +358 -0
  7. package/drizzle/0000_dapper_the_professor.sql +159 -0
  8. package/drizzle/0001_api_keys.sql +51 -0
  9. package/drizzle/meta/0000_snapshot.json +1532 -0
  10. package/drizzle/meta/_journal.json +13 -0
  11. package/drizzle.config.ts +20 -0
  12. package/package.json +114 -0
  13. package/scripts/add-extraction-job.ts +122 -0
  14. package/scripts/benchmark-pgvector.ts +122 -0
  15. package/scripts/bootstrap.sh +209 -0
  16. package/scripts/check-runtime-pack.ts +111 -0
  17. package/scripts/claude-mcp-config.ts +336 -0
  18. package/scripts/docker-entrypoint.sh +183 -0
  19. package/scripts/doctor.ts +377 -0
  20. package/scripts/init-db.sql +33 -0
  21. package/scripts/install.sh +1110 -0
  22. package/scripts/mcp-setup.ts +271 -0
  23. package/scripts/migrations/001_create_pgvector_extension.sql +31 -0
  24. package/scripts/migrations/002_create_memory_embeddings_table.sql +75 -0
  25. package/scripts/migrations/003_create_hnsw_index.sql +94 -0
  26. package/scripts/migrations/004_create_memory_embeddings_standalone.sql +70 -0
  27. package/scripts/migrations/005_create_chunks_table.sql +95 -0
  28. package/scripts/migrations/006_create_processing_queue.sql +45 -0
  29. package/scripts/migrations/generate_test_data.sql +42 -0
  30. package/scripts/migrations/phase1_comprehensive_test.sql +204 -0
  31. package/scripts/migrations/run_migrations.sh +286 -0
  32. package/scripts/migrations/test_hnsw_index.sql +255 -0
  33. package/scripts/pre-commit-secrets +282 -0
  34. package/scripts/run-extraction-worker.ts +46 -0
  35. package/scripts/run-phase1-tests.sh +291 -0
  36. package/scripts/setup.ts +222 -0
  37. package/scripts/smoke-install.sh +12 -0
  38. package/scripts/test-health-endpoint.sh +328 -0
  39. package/src/api/index.ts +2 -0
  40. package/src/api/middleware/auth.ts +80 -0
  41. package/src/api/middleware/csrf.ts +308 -0
  42. package/src/api/middleware/errorHandler.ts +166 -0
  43. package/src/api/middleware/rateLimit.ts +360 -0
  44. package/src/api/middleware/validation.ts +514 -0
  45. package/src/api/routes/documents.ts +286 -0
  46. package/src/api/routes/profiles.ts +237 -0
  47. package/src/api/routes/search.ts +71 -0
  48. package/src/api/stores/index.ts +58 -0
  49. package/src/config/bootstrap-env.ts +3 -0
  50. package/src/config/env.ts +71 -0
  51. package/src/config/feature-flags.ts +25 -0
  52. package/src/config/index.ts +140 -0
  53. package/src/config/secrets.config.ts +291 -0
  54. package/src/db/client.ts +92 -0
  55. package/src/db/index.ts +73 -0
  56. package/src/db/postgres.ts +72 -0
  57. package/src/db/schema/chunks.schema.ts +31 -0
  58. package/src/db/schema/containers.schema.ts +46 -0
  59. package/src/db/schema/documents.schema.ts +49 -0
  60. package/src/db/schema/embeddings.schema.ts +32 -0
  61. package/src/db/schema/index.ts +11 -0
  62. package/src/db/schema/memories.schema.ts +72 -0
  63. package/src/db/schema/profiles.schema.ts +34 -0
  64. package/src/db/schema/queue.schema.ts +59 -0
  65. package/src/db/schema/relationships.schema.ts +42 -0
  66. package/src/db/schema.ts +223 -0
  67. package/src/db/worker-connection.ts +47 -0
  68. package/src/index.ts +235 -0
  69. package/src/mcp/CLAUDE.md +1 -0
  70. package/src/mcp/index.ts +1380 -0
  71. package/src/mcp/legacyState.ts +22 -0
  72. package/src/mcp/rateLimit.ts +358 -0
  73. package/src/mcp/resources.ts +309 -0
  74. package/src/mcp/results.ts +104 -0
  75. package/src/mcp/tools.ts +401 -0
  76. package/src/queues/config.ts +119 -0
  77. package/src/queues/index.ts +289 -0
  78. package/src/sdk/client.ts +225 -0
  79. package/src/sdk/errors.ts +266 -0
  80. package/src/sdk/http.ts +560 -0
  81. package/src/sdk/index.ts +244 -0
  82. package/src/sdk/resources/base.ts +65 -0
  83. package/src/sdk/resources/connections.ts +204 -0
  84. package/src/sdk/resources/documents.ts +163 -0
  85. package/src/sdk/resources/index.ts +10 -0
  86. package/src/sdk/resources/memories.ts +150 -0
  87. package/src/sdk/resources/search.ts +60 -0
  88. package/src/sdk/resources/settings.ts +36 -0
  89. package/src/sdk/types.ts +674 -0
  90. package/src/services/chunking/index.ts +451 -0
  91. package/src/services/chunking.service.ts +650 -0
  92. package/src/services/csrf.service.ts +252 -0
  93. package/src/services/documents.repository.ts +219 -0
  94. package/src/services/documents.service.ts +191 -0
  95. package/src/services/embedding.service.ts +404 -0
  96. package/src/services/extraction.service.ts +300 -0
  97. package/src/services/extractors/code.extractor.ts +451 -0
  98. package/src/services/extractors/index.ts +9 -0
  99. package/src/services/extractors/markdown.extractor.ts +461 -0
  100. package/src/services/extractors/pdf.extractor.ts +315 -0
  101. package/src/services/extractors/text.extractor.ts +118 -0
  102. package/src/services/extractors/url.extractor.ts +243 -0
  103. package/src/services/index.ts +235 -0
  104. package/src/services/ingestion.service.ts +177 -0
  105. package/src/services/llm/anthropic.ts +400 -0
  106. package/src/services/llm/base.ts +460 -0
  107. package/src/services/llm/contradiction-detector.service.ts +526 -0
  108. package/src/services/llm/heuristics.ts +148 -0
  109. package/src/services/llm/index.ts +309 -0
  110. package/src/services/llm/memory-classifier.service.ts +383 -0
  111. package/src/services/llm/memory-extension-detector.service.ts +523 -0
  112. package/src/services/llm/mock.ts +470 -0
  113. package/src/services/llm/openai.ts +398 -0
  114. package/src/services/llm/prompts.ts +438 -0
  115. package/src/services/llm/types.ts +373 -0
  116. package/src/services/memory.repository.ts +1769 -0
  117. package/src/services/memory.service.ts +1338 -0
  118. package/src/services/memory.types.ts +234 -0
  119. package/src/services/persistence/index.ts +295 -0
  120. package/src/services/pipeline.service.ts +509 -0
  121. package/src/services/profile.repository.ts +436 -0
  122. package/src/services/profile.service.ts +560 -0
  123. package/src/services/profile.types.ts +270 -0
  124. package/src/services/relationships/detector.ts +1128 -0
  125. package/src/services/relationships/index.ts +268 -0
  126. package/src/services/relationships/memory-integration.ts +459 -0
  127. package/src/services/relationships/strategies.ts +132 -0
  128. package/src/services/relationships/types.ts +370 -0
  129. package/src/services/search.service.ts +761 -0
  130. package/src/services/search.types.ts +220 -0
  131. package/src/services/secrets.service.ts +384 -0
  132. package/src/services/vectorstore/base.ts +327 -0
  133. package/src/services/vectorstore/index.ts +444 -0
  134. package/src/services/vectorstore/memory.ts +286 -0
  135. package/src/services/vectorstore/migration.ts +295 -0
  136. package/src/services/vectorstore/mock.ts +403 -0
  137. package/src/services/vectorstore/pgvector.ts +695 -0
  138. package/src/services/vectorstore/types.ts +247 -0
  139. package/src/startup.ts +389 -0
  140. package/src/types/api.types.ts +193 -0
  141. package/src/types/document.types.ts +103 -0
  142. package/src/types/index.ts +241 -0
  143. package/src/types/profile.base.ts +133 -0
  144. package/src/utils/errors.ts +447 -0
  145. package/src/utils/id.ts +15 -0
  146. package/src/utils/index.ts +101 -0
  147. package/src/utils/logger.ts +313 -0
  148. package/src/utils/sanitization.ts +501 -0
  149. package/src/utils/secret-validation.ts +273 -0
  150. package/src/utils/synonyms.ts +188 -0
  151. package/src/utils/validation.ts +581 -0
  152. package/src/workers/chunking.worker.ts +242 -0
  153. package/src/workers/embedding.worker.ts +358 -0
  154. package/src/workers/extraction.worker.ts +346 -0
  155. package/src/workers/indexing.worker.ts +505 -0
  156. 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
+ }
@@ -0,0 +1,3 @@
1
+ import { loadEnvFile } from './env.js'
2
+
3
+ loadEnvFile()