@ossy/platform 3.18.0 → 3.19.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ossy/platform",
3
- "version": "3.18.0",
3
+ "version": "3.19.0",
4
4
  "description": "Ossy application server runtime",
5
5
  "repository": {
6
6
  "type": "git",
@@ -30,7 +30,8 @@
30
30
  "./verify-sign-in-session": "./src/verify-sign-in-session.js",
31
31
  "./storage-keys": "./src/storage/storage-keys.js",
32
32
  "./mcp": "./src/mcp/mount-platform-mcp.js",
33
- "./metering": "./src/metering/index.js"
33
+ "./metering": "./src/metering/index.js",
34
+ "./vector/vector-store": "./src/vector/vector-store.js"
34
35
  },
35
36
  "scripts": {
36
37
  "start": "PORT=3003 node -e \"import('./src/server.js').then(m => m.startServer())\"",
@@ -47,7 +48,7 @@
47
48
  "@aws-sdk/util-format-url": "^3.972.17",
48
49
  "@modelcontextprotocol/sdk": "^1.12.1",
49
50
  "@ossy/config": "^3.0.9",
50
- "@ossy/event-store": "^3.18.0",
51
+ "@ossy/event-store": "^3.19.0",
51
52
  "@ossy/locale": "^3.4.0",
52
53
  "@ossy/manifest": "^3.18.0",
53
54
  "@ossy/observability": "^3.0.9",
@@ -78,5 +79,5 @@
78
79
  "Dockerfile",
79
80
  "docker-healthcheck.js"
80
81
  ],
81
- "gitHead": "8ceef98795a01c3b3486034db1d3b72b4350ccf1"
82
+ "gitHead": "0ac103001a468f56597c02cf1c6802bf7bd89b33"
82
83
  }
@@ -55,6 +55,7 @@ export class TaskRunListProjection {
55
55
  startedAt: event.created,
56
56
  completedAt: event.created,
57
57
  durationMs: event.payload.durationMs,
58
+ triggeredBy: event.payload.triggeredBy ?? null,
58
59
  ...(event.payload.inputs ? { inputs: event.payload.inputs } : {}),
59
60
  })
60
61
  return { workspaceId, runs: runs.slice(0, 200) }
@@ -68,6 +69,7 @@ export class TaskRunListProjection {
68
69
  durationMs: event.payload.durationMs,
69
70
  error: event.event === 'Failed' ? event.payload.error : undefined,
70
71
  resultSummary: event.event === 'Completed' ? event.payload.resultSummary : undefined,
72
+ triggeredBy: runs[idx].triggeredBy ?? event.payload.triggeredBy ?? null,
71
73
  ...(event.payload.inputs ? { inputs: event.payload.inputs } : {}),
72
74
  }
73
75
 
@@ -108,6 +108,7 @@ export const TaskRunService = {
108
108
  durationMs,
109
109
  resultSummary: summarizeResult(result),
110
110
  inputs: summarizeInputs(context.inputs),
111
+ triggeredBy,
111
112
  })
112
113
  recordTaskMetrics(taskId, durationMs)
113
114
  const usage = extractUsage(result)
@@ -136,6 +137,7 @@ export const TaskRunService = {
136
137
  durationMs,
137
138
  error: summarizeError(error),
138
139
  inputs: summarizeInputs(context.inputs),
140
+ triggeredBy,
139
141
  })
140
142
  recordTaskMetrics(taskId, durationMs)
141
143
  await MeteringService.record({
@@ -199,7 +201,7 @@ export const TaskRunService = {
199
201
  }
200
202
  },
201
203
 
202
- async _appendCompleted ({ runId, taskId, workspaceId, actorId, durationMs, resultSummary, inputs }) {
204
+ async _appendCompleted ({ runId, taskId, workspaceId, actorId, durationMs, resultSummary, inputs, triggeredBy }) {
203
205
  try {
204
206
  await Aggregate.Of(TaskRun, runId)
205
207
  .then(Aggregate.Add({
@@ -209,6 +211,7 @@ export const TaskRunService = {
209
211
  durationMs,
210
212
  resultSummary,
211
213
  ...(inputs ? { inputs } : {}),
214
+ ...(triggeredBy ? { triggeredBy } : {}),
212
215
  ...taskRunScopePayload({ taskId, workspaceId }),
213
216
  },
214
217
  }))
@@ -217,7 +220,7 @@ export const TaskRunService = {
217
220
  }
218
221
  },
219
222
 
220
- async _appendFailed ({ runId, taskId, workspaceId, actorId, durationMs, error, inputs }) {
223
+ async _appendFailed ({ runId, taskId, workspaceId, actorId, durationMs, error, inputs, triggeredBy }) {
221
224
  try {
222
225
  await Aggregate.Of(TaskRun, runId)
223
226
  .then(Aggregate.Add({
@@ -227,6 +230,7 @@ export const TaskRunService = {
227
230
  durationMs,
228
231
  error,
229
232
  ...(inputs ? { inputs } : {}),
233
+ ...(triggeredBy ? { triggeredBy } : {}),
230
234
  ...taskRunScopePayload({ taskId, workspaceId }),
231
235
  },
232
236
  }))
@@ -20,7 +20,7 @@ export class TaskRun {
20
20
  moduleId: event.payload.moduleId,
21
21
  status: 'in progress',
22
22
  trigger: event.payload.trigger,
23
- triggeredBy: event.payload.triggeredBy ?? {},
23
+ triggeredBy: event.payload.triggeredBy ?? null,
24
24
  actionInvocationId: event.payload.actionInvocationId ?? null,
25
25
  actorId: event.payload.actorId ?? event.createdBy ?? null,
26
26
  workspaceId: event.payload.belongsTo ?? null,
@@ -36,6 +36,7 @@ export class TaskRun {
36
36
  completedAt: event.created,
37
37
  durationMs: event.payload.durationMs,
38
38
  resultSummary: event.payload.resultSummary ?? null,
39
+ triggeredBy: run.triggeredBy ?? event.payload.triggeredBy ?? null,
39
40
  ...(event.payload.inputs ? { inputs: event.payload.inputs } : {}),
40
41
  }
41
42
 
@@ -46,6 +47,7 @@ export class TaskRun {
46
47
  completedAt: event.created,
47
48
  durationMs: event.payload.durationMs,
48
49
  error: event.payload.error ?? null,
50
+ triggeredBy: run.triggeredBy ?? event.payload.triggeredBy ?? null,
49
51
  ...(event.payload.inputs ? { inputs: event.payload.inputs } : {}),
50
52
  }
51
53
 
@@ -54,19 +54,24 @@ export function mountOssyMcp ({ app, capabilities, invokeAction, requireAuth, cu
54
54
 
55
55
  app.post(path, handleMcpPost)
56
56
 
57
- app.get(path, (_req, res) => {
58
- res.status(405).json({
59
- jsonrpc: '2.0',
60
- error: { code: -32000, message: 'Method not allowed.' },
61
- id: null,
57
+ // GET /mcp — Streamable HTTP clients (e.g. Cursor) open this for the
58
+ // server→client SSE channel after initialize. We don't send server-initiated
59
+ // messages, so keep the stream idle until the client closes it.
60
+ // Returning 405 here blocks tool discovery entirely.
61
+ app.get(path, (req, res) => {
62
+ if (requireAuth && !requireAuth(req, res)) return
63
+ res.writeHead(200, {
64
+ 'Content-Type': 'text/event-stream',
65
+ 'Cache-Control': 'no-cache',
66
+ Connection: 'keep-alive',
62
67
  })
68
+ res.write('\n')
69
+ req.on('close', () => res.end())
63
70
  })
64
71
 
65
- app.delete(path, (_req, res) => {
66
- res.status(405).json({
67
- jsonrpc: '2.0',
68
- error: { code: -32000, message: 'Method not allowed.' },
69
- id: null,
70
- })
72
+ // DELETE /mcp — session teardown; stateless server has nothing to clean up.
73
+ app.delete(path, (req, res) => {
74
+ if (requireAuth && !requireAuth(req, res)) return
75
+ res.status(200).end()
71
76
  })
72
77
  }
@@ -0,0 +1,81 @@
1
+ import { afterEach, describe, expect, it } from '@jest/globals'
2
+ import express from 'express'
3
+ import { mountOssyMcp } from './mount-ossy-mcp.js'
4
+
5
+ function mountTestApp () {
6
+ const app = express()
7
+ app.use(express.json({ type: ['application/json', 'application/*+json'] }))
8
+ mountOssyMcp({
9
+ app,
10
+ capabilities: { tools: [], tasks: [], graph: { edges: [] } },
11
+ invokeAction: async () => ({ ok: true }),
12
+ })
13
+ return app
14
+ }
15
+
16
+ async function listen (app) {
17
+ const server = await new Promise((resolve) => {
18
+ const s = app.listen(0, '127.0.0.1', () => resolve(s))
19
+ })
20
+ return {
21
+ server,
22
+ baseUrl: `http://127.0.0.1:${server.address().port}`,
23
+ }
24
+ }
25
+
26
+ describe('mountOssyMcp', () => {
27
+ let server
28
+
29
+ afterEach(async () => {
30
+ if (server) {
31
+ await new Promise((resolve) => server.close(resolve))
32
+ server = undefined
33
+ }
34
+ })
35
+
36
+ it('GET /mcp returns 200 text/event-stream (not 405)', async () => {
37
+ ;({ server, baseUrl } = await listen(mountTestApp()))
38
+
39
+ const controller = new AbortController()
40
+ const res = await fetch(`${baseUrl}/mcp`, {
41
+ method: 'GET',
42
+ headers: { accept: 'text/event-stream' },
43
+ signal: controller.signal,
44
+ })
45
+
46
+ expect(res.status).toBe(200)
47
+ expect(res.headers.get('content-type')).toMatch(/text\/event-stream/)
48
+ controller.abort()
49
+ })
50
+
51
+ it('DELETE /mcp returns 200', async () => {
52
+ ;({ server, baseUrl } = await listen(mountTestApp()))
53
+
54
+ const res = await fetch(`${baseUrl}/mcp`, { method: 'DELETE' })
55
+ expect(res.status).toBe(200)
56
+ })
57
+
58
+ it('POST /mcp initialize returns 200', async () => {
59
+ ;({ server, baseUrl } = await listen(mountTestApp()))
60
+
61
+ const res = await fetch(`${baseUrl}/mcp`, {
62
+ method: 'POST',
63
+ headers: {
64
+ 'content-type': 'application/json',
65
+ accept: 'application/json, text/event-stream',
66
+ },
67
+ body: JSON.stringify({
68
+ jsonrpc: '2.0',
69
+ id: 1,
70
+ method: 'initialize',
71
+ params: {
72
+ protocolVersion: '2025-03-26',
73
+ capabilities: {},
74
+ clientInfo: { name: 'test', version: '0.0.0' },
75
+ },
76
+ }),
77
+ })
78
+
79
+ expect(res.status).toBe(200)
80
+ })
81
+ })
package/src/server.js CHANGED
@@ -8,6 +8,7 @@ import cookieParser from 'cookie-parser'
8
8
  import { ProxyInternal } from './proxy-internal.js'
9
9
  import { SDK, resolveActionId } from '@ossy/sdk'
10
10
  import { AggregateRebuild, ensureEventStoreIndexes, Mongo, resolveMongoUrl } from '@ossy/event-store'
11
+ import { ensureEmbeddingIndexes } from './vector/vector-store.js'
11
12
  import { TaskService } from './tasks/task-service.js'
12
13
  import { ChangeStream } from './tasks/change-stream.js'
13
14
  import { buildManifestSummary } from '@ossy/manifest/build-manifest-summary'
@@ -300,6 +301,9 @@ export async function startServer (options = {}) {
300
301
  await ensureEventStoreIndexes().catch((err) => {
301
302
  log.error('Failed to ensure MongoDB indexes — aggregate reads may be very slow', undefined, err)
302
303
  })
304
+ ensureEmbeddingIndexes().catch((err) => {
305
+ log.error('Failed to ensure vector embedding indexes', undefined, err)
306
+ })
303
307
  ChangeStream.start(resolveMongoUrl(process.env.DB_URL))
304
308
  }
305
309
 
@@ -384,6 +388,20 @@ export async function startServer (options = {}) {
384
388
 
385
389
  const payload = req.body?.payload ?? {}
386
390
 
391
+ // Fall back to reading the workspace from the session cookie when the
392
+ // client did not send the workspaceId header (e.g. SDK workspaceId was
393
+ // not initialised from SSR props).
394
+ if (!req.workspaceId) {
395
+ const cookieWorkspaceId = readWorkspaceIdFromCookies(req)
396
+ if (cookieWorkspaceId) req.workspaceId = cookieWorkspaceId
397
+ }
398
+ // Last resort: accept workspaceId from the request payload itself.
399
+ // This is no less secure than a header — actual workspace membership is
400
+ // enforced inside the action handler.
401
+ if (!req.workspaceId && typeof payload.workspaceId === 'string' && payload.workspaceId) {
402
+ req.workspaceId = payload.workspaceId
403
+ }
404
+
387
405
  const action = ActionService.get(actionId)
388
406
  if (!action) return res.status(404).json({ error: 'Action not found' })
389
407
 
@@ -83,6 +83,20 @@ export function createS3StorageClient ({
83
83
  return s3Client.send(new HeadObjectCommand({ Bucket: bucket, Key: toS3Key(normalizeLogicalKey({ Key })) }))
84
84
  },
85
85
 
86
+ async save (key, body, { ContentType } = {}) {
87
+ const logicalKey = normalizeLogicalKey(key)
88
+ const Key = toS3Key(logicalKey)
89
+ const s3Client = createAwsClient()
90
+ const Body = Buffer.isBuffer(body) ? body : Buffer.from(body)
91
+ log.info('[S3Storage][save] Putting object', { key: logicalKey, bytes: Body.length })
92
+ await s3Client.send(new PutObjectCommand({
93
+ Bucket: bucket,
94
+ Key,
95
+ Body,
96
+ ...(ContentType ? { ContentType } : {}),
97
+ }))
98
+ },
99
+
86
100
  async load (keyOrShape) {
87
101
  const Key = toS3Key(normalizeLogicalKey(keyOrShape))
88
102
  const s3Client = createAwsClient()
@@ -0,0 +1,72 @@
1
+ import { describe, expect, it, jest, beforeEach } from '@jest/globals'
2
+
3
+ const send = jest.fn()
4
+
5
+ jest.unstable_mockModule('@aws-sdk/client-s3', () => ({
6
+ S3: jest.fn().mockImplementation(() => ({ send })),
7
+ PutObjectCommand: jest.fn().mockImplementation((input) => ({ input, _type: 'PutObject' })),
8
+ GetObjectCommand: jest.fn().mockImplementation((input) => ({ input, _type: 'GetObject' })),
9
+ HeadObjectCommand: jest.fn().mockImplementation((input) => ({ input, _type: 'HeadObject' })),
10
+ }))
11
+
12
+ jest.unstable_mockModule('@aws-sdk/s3-request-presigner', () => ({
13
+ S3RequestPresigner: jest.fn(),
14
+ }))
15
+
16
+ jest.unstable_mockModule('@aws-sdk/util-create-request', () => ({
17
+ createRequest: jest.fn(),
18
+ }))
19
+
20
+ jest.unstable_mockModule('@aws-sdk/util-format-url', () => ({
21
+ formatUrl: jest.fn(),
22
+ }))
23
+
24
+ const { createS3StorageClient } = await import('./s3-storage.client.js')
25
+ const { PutObjectCommand } = await import('@aws-sdk/client-s3')
26
+
27
+ describe('createS3StorageClient.save', () => {
28
+ beforeEach(() => {
29
+ send.mockReset()
30
+ send.mockResolvedValue({})
31
+ PutObjectCommand.mockClear()
32
+ })
33
+
34
+ it('exposes save and puts objects under the media/ prefix', async () => {
35
+ const client = createS3StorageClient({
36
+ bucket: 'media-bucket',
37
+ accessKeyId: 'ak',
38
+ secretAccessKey: 'sk',
39
+ })
40
+
41
+ expect(typeof client.save).toBe('function')
42
+
43
+ const body = Buffer.from('{"ok":true}')
44
+ await client.save('abc123:task-extract-colors-palette', body, {
45
+ ContentType: 'application/json',
46
+ })
47
+
48
+ expect(PutObjectCommand).toHaveBeenCalledWith({
49
+ Bucket: 'media-bucket',
50
+ Key: 'media/abc123:task-extract-colors-palette',
51
+ Body: body,
52
+ ContentType: 'application/json',
53
+ })
54
+ expect(send).toHaveBeenCalledTimes(1)
55
+ })
56
+
57
+ it('accepts string bodies without ContentType', async () => {
58
+ const client = createS3StorageClient({
59
+ bucket: 'media-bucket',
60
+ accessKeyId: 'ak',
61
+ secretAccessKey: 'sk',
62
+ })
63
+
64
+ await client.save('file-id', 'hello')
65
+
66
+ expect(PutObjectCommand).toHaveBeenCalledWith({
67
+ Bucket: 'media-bucket',
68
+ Key: 'media/file-id',
69
+ Body: Buffer.from('hello'),
70
+ })
71
+ })
72
+ })
@@ -221,9 +221,12 @@ export class TaskService {
221
221
  }
222
222
 
223
223
  const entry = { metadata, handler }
224
- TaskService._tasks.push(entry)
225
- if (TaskService._tasksById.has(metadata.id)) {
224
+ const existingIdx = TaskService._tasks.findIndex((t) => t.metadata.id === metadata.id)
225
+ if (existingIdx >= 0) {
226
226
  _serviceLog.warn(`Task "${metadata.id}" already registered — overwriting`)
227
+ TaskService._tasks[existingIdx] = entry
228
+ } else {
229
+ TaskService._tasks.push(entry)
227
230
  }
228
231
  TaskService._tasksById.set(metadata.id, entry)
229
232
  _serviceLog.info(
@@ -271,8 +274,9 @@ export class TaskService {
271
274
  context: invokeContext,
272
275
  trigger: 'invoke',
273
276
  triggeredBy: {
274
- channel: context.req ? undefined : 'api',
277
+ ...(context.req ? {} : { channel: 'api' }),
275
278
  actionInvocationId: context.actionInvocationId ?? null,
279
+ ...(context.inputs?.resourceId ? { resourceId: context.inputs.resourceId } : {}),
276
280
  },
277
281
  executionEnv: 'ossy_server',
278
282
  audit,
@@ -253,6 +253,36 @@ describe('TaskRunList projection', () => {
253
253
  expect(state.runs[0].inputs).toEqual({ model: 'google/gemini-2.5-flash-lite' })
254
254
  })
255
255
 
256
+ it('keeps triggeredBy when Failed arrives without a prior Started in the list', () => {
257
+ let state = TaskRunListProjection.initialState('ws1')
258
+ state = TaskRunListProjection.Apply({
259
+ resourceId: 'run-orphan',
260
+ type: '@ossy/platform/schema/task-run',
261
+ event: 'Failed',
262
+ created: 3000,
263
+ payload: {
264
+ durationMs: 800,
265
+ error: { message: 'storage backend missing method "save"' },
266
+ belongsTo: 'ws1',
267
+ taskId: '@ossy/media-tasks/tasks/resize-common-web',
268
+ moduleId: 'media-tasks',
269
+ triggeredBy: {
270
+ resourceId: 'file-xyz',
271
+ type: '@ossy/platform/schema/file',
272
+ event: 'Created',
273
+ },
274
+ },
275
+ }, state)
276
+
277
+ expect(state.runs).toHaveLength(1)
278
+ expect(state.runs[0].status).toBe('failed')
279
+ expect(state.runs[0].triggeredBy).toEqual({
280
+ resourceId: 'file-xyz',
281
+ type: '@ossy/platform/schema/file',
282
+ event: 'Created',
283
+ })
284
+ })
285
+
256
286
  it('scopes Completed/Failed from payload.belongsTo (changestream dispatch)', () => {
257
287
  expect(TaskRunListProjection.scopeFromEvent({
258
288
  payload: { durationMs: 10 },
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Vector embedding store backed by MongoDB.
3
+ *
4
+ * Collection: `resource_embeddings`
5
+ * Schema: { resourceId, workspaceId, model, dimensions, vector, textSource, textLength, updatedAt }
6
+ *
7
+ * Search strategy:
8
+ * - If Atlas Vector Search index `resource_embedding_vector` is present → $vectorSearch
9
+ * - Otherwise → in-memory cosine similarity (works fine for <10k resources per workspace)
10
+ *
11
+ * Atlas Vector Search index definition (create via Atlas UI or Admin API):
12
+ * ```json
13
+ * {
14
+ * "mappings": {
15
+ * "dynamic": false,
16
+ * "fields": {
17
+ * "vector": { "type": "knnVector", "dimensions": 1536, "similarity": "cosine" }
18
+ * }
19
+ * }
20
+ * }
21
+ * ```
22
+ */
23
+
24
+ import { createLogger } from '@ossy/observability'
25
+ import { Mongo } from '@ossy/event-store'
26
+
27
+ const log = createLogger('resources')
28
+
29
+ const COLLECTION = 'resource_embeddings'
30
+ const ATLAS_INDEX = 'resource_embedding_vector'
31
+
32
+ /**
33
+ * Cosine similarity between two equal-length float arrays.
34
+ *
35
+ * @param {number[]} a
36
+ * @param {number[]} b
37
+ * @returns {number}
38
+ */
39
+ export function cosineSimilarity (a, b) {
40
+ let dot = 0
41
+ let magA = 0
42
+ let magB = 0
43
+ for (let i = 0; i < a.length; i++) {
44
+ dot += a[i] * b[i]
45
+ magA += a[i] * a[i]
46
+ magB += b[i] * b[i]
47
+ }
48
+ const denom = Math.sqrt(magA) * Math.sqrt(magB)
49
+ return denom === 0 ? 0 : dot / denom
50
+ }
51
+
52
+ /**
53
+ * @returns {import('mongodb').Collection}
54
+ */
55
+ function col () {
56
+ return Mongo.db.collection(COLLECTION)
57
+ }
58
+
59
+ /**
60
+ * Upsert a resource embedding.
61
+ *
62
+ * @param {{
63
+ * resourceId: string,
64
+ * workspaceId: string,
65
+ * model: string,
66
+ * dimensions: number,
67
+ * vector: number[],
68
+ * textSource: string,
69
+ * textLength: number,
70
+ * }} doc
71
+ */
72
+ export async function upsertEmbedding (doc) {
73
+ await col().updateOne(
74
+ { resourceId: doc.resourceId },
75
+ {
76
+ $set: {
77
+ ...doc,
78
+ updatedAt: new Date(),
79
+ },
80
+ },
81
+ { upsert: true },
82
+ )
83
+ }
84
+
85
+ /**
86
+ * Delete the embedding for a resource (e.g. when the resource is removed).
87
+ *
88
+ * @param {string} resourceId
89
+ */
90
+ export async function deleteEmbedding (resourceId) {
91
+ await col().deleteOne({ resourceId })
92
+ }
93
+
94
+ /**
95
+ * Semantic search within a workspace using cosine similarity.
96
+ * Tries Atlas $vectorSearch first; falls back to in-memory similarity.
97
+ *
98
+ * @param {{
99
+ * workspaceId: string,
100
+ * queryVector: number[],
101
+ * limit?: number,
102
+ * threshold?: number,
103
+ * }} opts
104
+ * @returns {Promise<Array<{ resourceId: string, score: number }>>}
105
+ */
106
+ export async function searchEmbeddings ({ workspaceId, queryVector, limit = 20, threshold = 0 }) {
107
+ // Try Atlas Vector Search (requires index on Atlas M10+).
108
+ try {
109
+ const pipeline = [
110
+ {
111
+ $vectorSearch: {
112
+ index: ATLAS_INDEX,
113
+ path: 'vector',
114
+ queryVector,
115
+ numCandidates: limit * 10,
116
+ limit,
117
+ filter: { workspaceId },
118
+ },
119
+ },
120
+ {
121
+ $project: {
122
+ _id: 0,
123
+ resourceId: 1,
124
+ score: { $meta: 'vectorSearchScore' },
125
+ },
126
+ },
127
+ ]
128
+ const results = await col().aggregate(pipeline).toArray()
129
+ if (results.length > 0) {
130
+ return results.filter((r) => r.score >= threshold)
131
+ }
132
+ } catch {
133
+ // Atlas Vector Search not available — fall through to cosine similarity.
134
+ }
135
+
136
+ // In-memory cosine similarity fallback.
137
+ log.debug('[VectorStore] Atlas $vectorSearch unavailable — using in-memory cosine similarity')
138
+ const docs = await col()
139
+ .find({ workspaceId })
140
+ .project({ resourceId: 1, vector: 1 })
141
+ .toArray()
142
+
143
+ return docs
144
+ .map((doc) => ({
145
+ resourceId: doc.resourceId,
146
+ score: cosineSimilarity(queryVector, doc.vector),
147
+ }))
148
+ .filter((r) => r.score >= threshold)
149
+ .sort((a, b) => b.score - a.score)
150
+ .slice(0, limit)
151
+ }
152
+
153
+ /**
154
+ * Load all embeddings for a workspace (for clustering).
155
+ *
156
+ * @param {string} workspaceId
157
+ * @returns {Promise<Array<{ resourceId: string, vector: number[] }>>}
158
+ */
159
+ export async function loadWorkspaceEmbeddings (workspaceId) {
160
+ return col()
161
+ .find({ workspaceId })
162
+ .project({ resourceId: 1, vector: 1, _id: 0 })
163
+ .toArray()
164
+ }
165
+
166
+ /**
167
+ * Ensure MongoDB indexes for the resource_embeddings collection.
168
+ * Call once at server startup alongside ensureEventStoreIndexes().
169
+ */
170
+ export async function ensureEmbeddingIndexes () {
171
+ const c = col()
172
+ await c.createIndex({ resourceId: 1 }, { name: 'resourceId', unique: true, background: true })
173
+ await c.createIndex({ workspaceId: 1 }, { name: 'workspaceId', background: true })
174
+ await c.createIndex({ workspaceId: 1, updatedAt: -1 }, { name: 'workspaceId_updatedAt', background: true })
175
+ log.info('[VectorStore] resource_embeddings indexes ensured')
176
+ }