@lota-sdk/core 0.1.6 → 0.1.7

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.
@@ -18,6 +18,7 @@ DEFINE FIELD IF NOT EXISTS lastCompactedMessageId ON TABLE workstream TYPE optio
18
18
  DEFINE FIELD IF NOT EXISTS nameGenerated ON TABLE workstream TYPE bool DEFAULT false;
19
19
  DEFINE FIELD IF NOT EXISTS isCompacting ON TABLE workstream TYPE bool DEFAULT false;
20
20
  DEFINE FIELD IF NOT EXISTS state ON TABLE workstream TYPE option<object> FLEXIBLE;
21
+ DEFINE FIELD IF NOT EXISTS turnCount ON TABLE workstream TYPE int DEFAULT 0;
21
22
 
22
23
  DEFINE INDEX IF NOT EXISTS workstreamOrgIdx ON TABLE workstream COLUMNS organizationId;
23
24
  DEFINE INDEX IF NOT EXISTS workstreamUserIdx ON TABLE workstream COLUMNS userId;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lota-sdk/core",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -0,0 +1,48 @@
1
+ import { createHash } from 'node:crypto'
2
+
3
+ import type IORedis from 'ioredis'
4
+
5
+ import { aiLogger } from '../config/logger'
6
+
7
+ const DEFAULT_TTL_SECONDS = 3600
8
+
9
+ export class EmbeddingCache {
10
+ constructor(
11
+ private redis: IORedis,
12
+ private ttlSeconds: number = DEFAULT_TTL_SECONDS,
13
+ ) {}
14
+
15
+ private buildKey(model: string, text: string): string {
16
+ const hash = createHash('sha256').update(text).digest('hex')
17
+ return `emb:${model}:${hash}`
18
+ }
19
+
20
+ async get(model: string, text: string): Promise<number[] | null> {
21
+ try {
22
+ const cached = await this.redis.getBuffer(this.buildKey(model, text))
23
+ if (!cached) return null
24
+ return JSON.parse(cached.toString()) as number[]
25
+ } catch (error) {
26
+ aiLogger.debug`Embedding cache get failed: ${error}`
27
+ return null
28
+ }
29
+ }
30
+
31
+ async set(model: string, text: string, embedding: number[]): Promise<void> {
32
+ try {
33
+ await this.redis.set(this.buildKey(model, text), JSON.stringify(embedding), 'EX', this.ttlSeconds)
34
+ } catch (error) {
35
+ aiLogger.debug`Embedding cache set failed: ${error}`
36
+ }
37
+ }
38
+ }
39
+
40
+ let embeddingCacheInstance: EmbeddingCache | null = null
41
+
42
+ export function configureEmbeddingCache(redis: IORedis, ttlSeconds?: number): void {
43
+ embeddingCacheInstance = new EmbeddingCache(redis, ttlSeconds ?? DEFAULT_TTL_SECONDS)
44
+ }
45
+
46
+ export function getEmbeddingCache(): EmbeddingCache | null {
47
+ return embeddingCacheInstance
48
+ }
@@ -0,0 +1,33 @@
1
+ export interface BackgroundProcessingConfig {
2
+ memoryExtractionFrequency: number
3
+ skillExtractionFrequency: number
4
+ memoryDigestFrequency: number
5
+ memoryConsolidationFrequency: number
6
+ }
7
+
8
+ const DEFAULT_CONFIG: BackgroundProcessingConfig = {
9
+ memoryExtractionFrequency: 3,
10
+ skillExtractionFrequency: 5,
11
+ memoryDigestFrequency: 1,
12
+ memoryConsolidationFrequency: 10,
13
+ }
14
+
15
+ let resolvedConfig: BackgroundProcessingConfig = { ...DEFAULT_CONFIG }
16
+
17
+ export function configureBackgroundProcessing(config?: Partial<BackgroundProcessingConfig>): void {
18
+ resolvedConfig = {
19
+ memoryExtractionFrequency: config?.memoryExtractionFrequency ?? DEFAULT_CONFIG.memoryExtractionFrequency,
20
+ skillExtractionFrequency: config?.skillExtractionFrequency ?? DEFAULT_CONFIG.skillExtractionFrequency,
21
+ memoryDigestFrequency: config?.memoryDigestFrequency ?? DEFAULT_CONFIG.memoryDigestFrequency,
22
+ memoryConsolidationFrequency: config?.memoryConsolidationFrequency ?? DEFAULT_CONFIG.memoryConsolidationFrequency,
23
+ }
24
+ }
25
+
26
+ export function getBackgroundProcessingConfig(): BackgroundProcessingConfig {
27
+ return resolvedConfig
28
+ }
29
+
30
+ export function shouldRunAtFrequency(turnCount: number, frequency: number): boolean {
31
+ if (frequency <= 1) return true
32
+ return turnCount > 0 && turnCount % frequency === 0
33
+ }
@@ -22,6 +22,10 @@ export const OPENROUTER_XHIGH_REASONING_PROVIDER_OPTIONS = {
22
22
  openai: { forceReasoning: true, reasoningEffort: 'xhigh', reasoningSummary: 'auto' },
23
23
  } as const
24
24
 
25
+ export const OPENROUTER_MEDIUM_REASONING_PROVIDER_OPTIONS = {
26
+ openai: { forceReasoning: true, reasoningEffort: 'medium', reasoningSummary: 'auto' },
27
+ } as const
28
+
25
29
  export const OPENROUTER_LOW_REASONING_PROVIDER_OPTIONS = {
26
30
  openai: { forceReasoning: true, reasoningEffort: 'low', reasoningSummary: 'auto' },
27
31
  } as const
@@ -1,5 +1,7 @@
1
1
  import { BoundQuery, eq, inside } from 'surrealdb'
2
2
 
3
+ import { getEmbeddingCache } from '../ai/embedding-cache'
4
+ import { env } from '../config/env-shapes'
3
5
  import { aiLogger } from '../config/logger'
4
6
  import { DEFAULT_MEMORY_SEARCH_LIMIT } from '../config/search'
5
7
  import { createDefaultEmbeddings } from '../embeddings/provider'
@@ -7,7 +9,14 @@ import { memoryQueryBuilder } from './memory-query-builder'
7
9
  import type { RelationCounts } from './memory-store.helpers'
8
10
  import { hashContent, mapRowToMemoryRecord, processGraphAwareRows } from './memory-store.helpers'
9
11
  import type { BasicSearchRow, SurrealMemoryRow } from './memory-store.rows'
10
- import type { LinearNormalization, MemoryEvent, MemoryRecord, MemorySearchResult, RelationType } from './memory-types'
12
+ import type {
13
+ LinearNormalization,
14
+ MemoryEvent,
15
+ MemoryListOptions,
16
+ MemoryRecord,
17
+ MemorySearchResult,
18
+ RelationType,
19
+ } from './memory-types'
11
20
  import { ensureRecordId, recordIdToString } from './record-id'
12
21
  import type { RecordIdInput, RecordIdRef } from './record-id'
13
22
  import { databaseService } from './service'
@@ -33,6 +42,18 @@ export class SurrealMemoryStore {
33
42
 
34
43
  constructor(private embeddings: EmbeddingClient) {}
35
44
 
45
+ private toMetadataFieldPath(key: string): string {
46
+ const segments = key.split('.').map((segment) => segment.trim())
47
+ if (
48
+ segments.length === 0 ||
49
+ segments.some((segment) => segment.length === 0 || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(segment))
50
+ ) {
51
+ throw new Error(`Invalid memory metadata filter key: ${key}`)
52
+ }
53
+
54
+ return `metadata.${segments.join('.')}`
55
+ }
56
+
36
57
  private tokenizeQuery(query: string): string[] {
37
58
  return query
38
59
  .toLowerCase()
@@ -188,6 +209,7 @@ export class SurrealMemoryStore {
188
209
  const now = Date.now()
189
210
  this.pruneEmbeddingCache(now)
190
211
 
212
+ // L1: in-memory cache
191
213
  const cached = this.embeddingCache.get(cacheKey)
192
214
  if (cached && now - cached.ts <= EMBEDDING_CACHE_TTL_MS) {
193
215
  return cached.embedding
@@ -198,16 +220,32 @@ export class SurrealMemoryStore {
198
220
  return await inFlight
199
221
  }
200
222
 
201
- const request = this.embeddings
202
- .embedQuery(normalized)
203
- .then((embedding) => {
204
- this.embeddingCache.set(cacheKey, { embedding, ts: Date.now() })
205
- this.pruneEmbeddingCache(Date.now())
206
- return embedding
207
- })
208
- .finally(() => {
209
- this.embeddingInFlight.delete(cacheKey)
210
- })
223
+ const request = (async () => {
224
+ // L2: Redis cache
225
+ const redisCache = getEmbeddingCache()
226
+ if (redisCache) {
227
+ const model = env.AI_EMBEDDING_MODEL
228
+ const redisCached = await redisCache.get(model, normalized)
229
+ if (redisCached) {
230
+ this.embeddingCache.set(cacheKey, { embedding: redisCached, ts: Date.now() })
231
+ return redisCached
232
+ }
233
+ }
234
+
235
+ // L3: API call
236
+ const embedding = await this.embeddings.embedQuery(normalized)
237
+ this.embeddingCache.set(cacheKey, { embedding, ts: Date.now() })
238
+ this.pruneEmbeddingCache(Date.now())
239
+
240
+ // Backfill Redis
241
+ if (redisCache) {
242
+ void redisCache.set(env.AI_EMBEDDING_MODEL, normalized, embedding)
243
+ }
244
+
245
+ return embedding
246
+ })().finally(() => {
247
+ this.embeddingInFlight.delete(cacheKey)
248
+ })
211
249
 
212
250
  this.embeddingInFlight.set(cacheKey, request)
213
251
  return await request
@@ -558,6 +596,8 @@ export class SurrealMemoryStore {
558
596
  return processed
559
597
  }
560
598
 
599
+ private static HYBRID_SEARCH_TIMEOUT_MS = 2000
600
+
561
601
  async hybridSearchWeighted(
562
602
  query: string,
563
603
  options: {
@@ -569,6 +609,7 @@ export class SurrealMemoryStore {
569
609
  fastMode?: boolean
570
610
  },
571
611
  ): Promise<MemorySearchResult[]> {
612
+ const searchStart = performance.now()
572
613
  const queryEmbedding = await this.generateEmbedding(query)
573
614
 
574
615
  const tokens = this.tokenizeQuery(query)
@@ -601,7 +642,25 @@ export class SurrealMemoryStore {
601
642
 
602
643
  type LinearRow = BasicSearchRow & { linearScore: number }
603
644
 
604
- const results = await this.queryFinalStatement<LinearRow>(sql, bindVars)
645
+ let results: LinearRow[]
646
+ try {
647
+ results = await Promise.race([
648
+ this.queryFinalStatement<LinearRow>(sql, bindVars),
649
+ new Promise<never>((_, reject) =>
650
+ setTimeout(() => reject(new Error('Hybrid search timeout')), SurrealMemoryStore.HYBRID_SEARCH_TIMEOUT_MS),
651
+ ),
652
+ ])
653
+ } catch {
654
+ const elapsed = performance.now() - searchStart
655
+ aiLogger.warn`Hybrid search timed out after ${elapsed.toFixed(0)}ms (scopeId: ${options.scopeId}). Falling back to vector-only.`
656
+ return this.vectorSearchWithEmbedding({
657
+ embedding: queryEmbedding,
658
+ scopeId: options.scopeId,
659
+ limit: options.limit,
660
+ memoryType: options.memoryType,
661
+ fastMode: options.fastMode,
662
+ })
663
+ }
605
664
 
606
665
  if (results.length === 0) {
607
666
  aiLogger.debug`Weighted hybrid search returned 0 raw results (scopeId: ${options.scopeId})`
@@ -644,7 +703,8 @@ export class SurrealMemoryStore {
644
703
  })
645
704
  }
646
705
 
647
- aiLogger.info`[SUCCESS_WEIGHTED_SEARCH] Weighted hybrid search succeeded (scopeId: ${options.scopeId}, rawResults: ${results.length}, returned: ${processed.length}, weights: ${weights.join(',')}, normalization: ${normalization})`
706
+ const elapsed = performance.now() - searchStart
707
+ aiLogger.info`[SUCCESS_WEIGHTED_SEARCH] Weighted hybrid search succeeded (scopeId: ${options.scopeId}, rawResults: ${results.length}, returned: ${processed.length}, weights: ${weights.join(',')}, normalization: ${normalization}, latencyMs: ${elapsed.toFixed(0)})`
648
708
  this.touchMemories(processed.map((row) => row.id))
649
709
  return processed
650
710
  }
@@ -709,16 +769,47 @@ export class SurrealMemoryStore {
709
769
  await this.recordHistory(id, existing.content, null, 'DELETE')
710
770
  }
711
771
 
712
- async list(scopeId: string, memoryType?: MemoryRecord['memoryType']): Promise<MemoryRecord[]> {
713
- const typeFilter = memoryType ? 'AND memoryType = $memoryType' : ''
772
+ async list(options: MemoryListOptions): Promise<MemoryRecord[]> {
773
+ const whereClauses = [
774
+ 'scopeId = $scopeId',
775
+ 'archivedAt IS NONE',
776
+ '(validUntil IS NONE OR validUntil > time::now())',
777
+ ]
778
+ const bindVars: Record<string, unknown> = { scopeId: options.scopeId }
779
+
780
+ if (options.memoryType) {
781
+ whereClauses.push('memoryType = $memoryType')
782
+ bindVars.memoryType = options.memoryType
783
+ }
784
+
785
+ for (const [index, [key, value]] of Object.entries(options.metadataEquals ?? {}).entries()) {
786
+ const fieldPath = this.toMetadataFieldPath(key)
787
+ const bindKey = `metadataEquals_${index}`
788
+ whereClauses.push(`${fieldPath} = $${bindKey}`)
789
+ bindVars[bindKey] = value
790
+ }
791
+
792
+ for (const [index, [key, value]] of Object.entries(options.metadataNotEquals ?? {}).entries()) {
793
+ const fieldPath = this.toMetadataFieldPath(key)
794
+ const bindKey = `metadataNotEquals_${index}`
795
+ whereClauses.push(`(${fieldPath} IS NONE OR ${fieldPath} != $${bindKey})`)
796
+ bindVars[bindKey] = value
797
+ }
798
+
799
+ const sortDirection = options.sort === 'createdAtAsc' ? 'ASC' : 'DESC'
800
+ const limitClause = typeof options.limit === 'number' ? 'LIMIT $limit' : ''
801
+ if (typeof options.limit === 'number') {
802
+ bindVars.limit = options.limit
803
+ }
804
+
714
805
  const sql = `
715
806
  SELECT * FROM ${MEMORY_TABLE}
716
- WHERE scopeId = $scopeId ${typeFilter}
717
- AND archivedAt IS NONE AND (validUntil IS NONE OR validUntil > time::now())
718
- ORDER BY createdAt DESC
807
+ WHERE ${whereClauses.join('\n AND ')}
808
+ ORDER BY createdAt ${sortDirection}
809
+ ${limitClause}
719
810
  `
720
811
 
721
- const results = await databaseService.query<SurrealMemoryRow>(new BoundQuery(sql, { scopeId, memoryType }))
812
+ const results = await databaseService.query<SurrealMemoryRow>(new BoundQuery(sql, bindVars))
722
813
 
723
814
  return results.map((row) => mapRowToMemoryRecord(row))
724
815
  }
@@ -55,6 +55,17 @@ export interface SearchOptions {
55
55
  pointInTime?: string
56
56
  }
57
57
 
58
+ export type MemoryListScalar = boolean | null | number | string
59
+
60
+ export interface MemoryListOptions {
61
+ scopeId: string
62
+ limit?: number
63
+ memoryType?: MemoryType
64
+ metadataEquals?: Record<string, MemoryListScalar>
65
+ metadataNotEquals?: Record<string, MemoryListScalar>
66
+ sort?: 'createdAtAsc' | 'createdAtDesc'
67
+ }
68
+
58
69
  export type LinearNormalization = 'minmax' | 'zscore'
59
70
 
60
71
  export interface WeightedSearchOptions extends SearchOptions {
package/src/db/memory.ts CHANGED
@@ -21,11 +21,13 @@ import type {
21
21
  Durability,
22
22
  ExtractedFact,
23
23
  MemoryConfig,
24
+ MemoryListOptions,
24
25
  MemorySearchResult,
25
26
  MemoryType,
26
27
  MemoryUpdateOutput,
27
28
  Message,
28
29
  MemoryRecord,
30
+ RelationType,
29
31
  SearchOptions,
30
32
  WeightedSearchOptions,
31
33
  } from './memory-types'
@@ -152,10 +154,18 @@ export class Memory {
152
154
  return await this.store.listTopMemories(options)
153
155
  }
154
156
 
157
+ async list(options: MemoryListOptions): Promise<MemoryRecord[]> {
158
+ return await this.store.list(options)
159
+ }
160
+
155
161
  async updateMemory(id: string, newContent: string): Promise<void> {
156
162
  await this.store.update(id, newContent)
157
163
  }
158
164
 
165
+ async addRelation(fromId: string, toId: string, relationType: RelationType, confidence = 1): Promise<void> {
166
+ await this.store.addRelation(fromId, toId, relationType, confidence)
167
+ }
168
+
159
169
  async getStaleMemories(scopeId: string, limit?: number): Promise<MemorySearchResult[]> {
160
170
  return await this.store.getStaleMemories(scopeId, limit)
161
171
  }
@@ -237,7 +247,7 @@ export class Memory {
237
247
  private async prepareFactsForScope(facts: ExtractedFact[], options: AddOptions): Promise<PreparedScopeUpdate> {
238
248
  const factMaps = buildMemoryFactMaps(facts)
239
249
 
240
- const existingMemories = await this.store.list(options.scopeId, options.memoryType)
250
+ const existingMemories = await this.store.list({ scopeId: options.scopeId, memoryType: options.memoryType })
241
251
  const oldMemoryFormat = existingMemories.map((m) => ({ id: m.id, text: m.content }))
242
252
 
243
253
  const factContents = facts.map((f) => f.content)
package/src/index.ts CHANGED
@@ -19,6 +19,7 @@ import type { routeWorkstreamChatMessages } from './runtime/chat-request-routing
19
19
  import type { LotaPlugin } from './runtime/plugin-types'
20
20
  import type { LotaRuntimeAdapters, LotaRuntimeTurnHooks } from './runtime/runtime-extensions'
21
21
  import type { attachmentService } from './services/attachment.service'
22
+ import type { documentChunkService } from './services/document-chunk.service'
22
23
  import type { executionPlanService } from './services/execution-plan.service'
23
24
  import type { memoryService } from './services/memory.service'
24
25
  import type { verifyMutatingApproval } from './services/mutating-approval.service'
@@ -74,8 +75,14 @@ export interface LotaRuntimeConfig {
74
75
  }
75
76
  firecrawl: { apiKey: string; apiBaseUrl?: string }
76
77
  logging?: { level?: 'trace' | 'debug' | 'info' | 'warning' | 'error' | 'fatal' }
77
- memory?: { searchK?: number }
78
+ memory?: { searchK?: number; embeddingCacheTtlSeconds?: number }
78
79
  workstreams?: LotaWorkstreamConfig
80
+ backgroundProcessing?: {
81
+ memoryExtractionFrequency?: number
82
+ skillExtractionFrequency?: number
83
+ memoryDigestFrequency?: number
84
+ memoryConsolidationFrequency?: number
85
+ }
79
86
 
80
87
  agents: {
81
88
  roster: readonly string[]
@@ -103,6 +110,7 @@ export interface LotaRuntime {
103
110
  redis: RedisConnectionManager
104
111
  closeRedisConnection: () => Promise<void>
105
112
  attachmentService: typeof attachmentService
113
+ documentChunkService: typeof documentChunkService
106
114
  generatedDocumentStorageService: typeof generatedDocumentStorageService
107
115
  memoryService: typeof memoryService
108
116
  verifyMutatingApproval: typeof verifyMutatingApproval
@@ -200,6 +208,20 @@ export async function createLotaRuntime(config: LotaRuntimeConfig): Promise<Lota
200
208
  const { configureWorkstreams } = await import('./config/workstream-defaults')
201
209
  const { configureRuntimeExtensions } = await import('./runtime/runtime-extensions')
202
210
  const { routeWorkstreamChatMessages } = await import('./runtime/chat-request-routing')
211
+ const { configureBackgroundProcessing } = await import('./config/background-processing')
212
+ const { configureEmbeddingCache } = await import('./ai/embedding-cache')
213
+
214
+ // Resolve config defaults
215
+ const memory = {
216
+ searchK: config.memory?.searchK ?? 6,
217
+ embeddingCacheTtlSeconds: config.memory?.embeddingCacheTtlSeconds ?? 3600,
218
+ }
219
+ const backgroundProcessing = {
220
+ memoryExtractionFrequency: config.backgroundProcessing?.memoryExtractionFrequency ?? 3,
221
+ skillExtractionFrequency: config.backgroundProcessing?.skillExtractionFrequency ?? 5,
222
+ memoryDigestFrequency: config.backgroundProcessing?.memoryDigestFrequency ?? 1,
223
+ memoryConsolidationFrequency: config.backgroundProcessing?.memoryConsolidationFrequency ?? 10,
224
+ }
203
225
 
204
226
  setEnv({
205
227
  AI_GATEWAY_URL: config.aiGateway.url,
@@ -217,7 +239,7 @@ export async function createLotaRuntime(config: LotaRuntimeConfig): Promise<Lota
217
239
  ATTACHMENT_URL_EXPIRES_IN: config.s3.attachmentUrlExpiresIn ?? 1800,
218
240
  FIRECRAWL_API_KEY: config.firecrawl.apiKey,
219
241
  FIRECRAWL_API_BASE_URL: config.firecrawl.apiBaseUrl,
220
- MEMORY_SEARCH_K: config.memory?.searchK ?? 6,
242
+ MEMORY_SEARCH_K: memory.searchK,
221
243
  })
222
244
 
223
245
  await configureLogger(config.logging?.level ?? 'info')
@@ -233,6 +255,8 @@ export async function createLotaRuntime(config: LotaRuntimeConfig): Promise<Lota
233
255
 
234
256
  const redisManager = createRedisConnectionManager({ url: config.redis.url })
235
257
  setRedisConnectionManager(redisManager)
258
+ configureEmbeddingCache(redisManager.getConnection(), memory.embeddingCacheTtlSeconds)
259
+ configureBackgroundProcessing(backgroundProcessing)
236
260
 
237
261
  configureAgents({
238
262
  roster: config.agents.roster,
@@ -253,6 +277,7 @@ export async function createLotaRuntime(config: LotaRuntimeConfig): Promise<Lota
253
277
  }
254
278
 
255
279
  const { attachmentService } = await import('./services/attachment.service')
280
+ const { documentChunkService } = await import('./services/document-chunk.service')
256
281
  const { recentActivityService } = await import('./services/recent-activity.service')
257
282
  const { recentActivityTitleService } = await import('./services/recent-activity-title.service')
258
283
  const { executionPlanService } = await import('./services/execution-plan.service')
@@ -392,6 +417,7 @@ export async function createLotaRuntime(config: LotaRuntimeConfig): Promise<Lota
392
417
  redis: redisManager,
393
418
  closeRedisConnection: async () => await redisManager.closeConnection(),
394
419
  attachmentService,
420
+ documentChunkService,
395
421
  generatedDocumentStorageService,
396
422
  memoryService,
397
423
  verifyMutatingApproval,
@@ -32,6 +32,12 @@ function getMemoryConsolidationQueue(): Queue<MemoryConsolidationJob> {
32
32
  return _memoryConsolidationQueue
33
33
  }
34
34
 
35
+ export async function enqueueMemoryConsolidation(job: MemoryConsolidationJob = {}) {
36
+ await getMemoryConsolidationQueue().add('consolidate-turn', job, {
37
+ jobId: job.scopeId ? `consolidate-turn:${job.scopeId}` : undefined,
38
+ })
39
+ }
40
+
35
41
  export async function scheduleRecurringConsolidation() {
36
42
  await getMemoryConsolidationQueue().add(
37
43
  'consolidate',
@@ -1,22 +1,5 @@
1
- import type { Output, PrepareStepFunction, StopCondition, ToolLoopAgentOnFinishCallback, ToolSet } from 'ai'
2
-
3
- export type ChatMode = 'direct' | 'workstreamMode' | 'fixedWorkstreamMode'
4
-
5
- export interface CreateRoutedAgentOptions<TTools extends ToolSet = ToolSet> {
6
- mode: ChatMode
7
- tools: TTools
8
- extraInstructions?: string
9
- stopWhen?: StopCondition<TTools> | Array<StopCondition<TTools>>
10
- prepareStep?: PrepareStepFunction<TTools>
11
- maxRetries?: number
12
- modelOverride?: { model: unknown; providerOptions?: Record<string, unknown> }
13
- onFinish?: ToolLoopAgentOnFinishCallback<TTools>
14
- }
15
-
16
- export interface CreateHelperToolLoopAgentOptions {
17
- instructions?: string
18
- maxOutputTokens?: number
19
- temperature?: number
20
- output?: Output.Output
21
- maxRetries?: number
22
- }
1
+ export type {
2
+ ChatMode,
3
+ CreateHelperToolLoopAgentOptions,
4
+ CreateRoutedAgentOptions,
5
+ } from '@lota-sdk/shared/runtime/agent-types'
@@ -1,3 +1,5 @@
1
+ import { getBackgroundProcessingConfig, shouldRunAtFrequency } from '../config/background-processing'
2
+
1
3
  export function shouldEnqueueOnboardingPostChatMemory(params: {
2
4
  onboardingActive: boolean
3
5
  userMessageText: string
@@ -9,6 +11,32 @@ export function shouldEnqueueOnboardingPostChatMemory(params: {
9
11
  return params.userMessageText.trim().length > 0 || params.hasAttachmentContext
10
12
  }
11
13
 
12
- export function shouldEnqueueRegularDigestForWorkstream(params: { onboardingActive: boolean }): boolean {
13
- return !params.onboardingActive
14
+ export function shouldEnqueueRegularDigestForWorkstream(params: {
15
+ onboardingActive: boolean
16
+ turnCount?: number
17
+ }): boolean {
18
+ if (params.onboardingActive) return false
19
+ const { memoryDigestFrequency } = getBackgroundProcessingConfig()
20
+ if (typeof params.turnCount === 'number') {
21
+ return shouldRunAtFrequency(params.turnCount, memoryDigestFrequency)
22
+ }
23
+ return true
24
+ }
25
+
26
+ export function shouldEnqueueMemoryExtraction(params: { onboardingActive: boolean; turnCount?: number }): boolean {
27
+ if (params.onboardingActive) return true
28
+ const { memoryExtractionFrequency } = getBackgroundProcessingConfig()
29
+ if (typeof params.turnCount === 'number') {
30
+ return shouldRunAtFrequency(params.turnCount, memoryExtractionFrequency)
31
+ }
32
+ return true
33
+ }
34
+
35
+ export function shouldEnqueueMemoryConsolidation(params: { onboardingActive: boolean; turnCount?: number }): boolean {
36
+ if (params.onboardingActive) return false
37
+ const { memoryConsolidationFrequency } = getBackgroundProcessingConfig()
38
+ if (typeof params.turnCount === 'number') {
39
+ return shouldRunAtFrequency(params.turnCount, memoryConsolidationFrequency)
40
+ }
41
+ return false
14
42
  }
@@ -1,3 +1,10 @@
1
- export function shouldEnqueueSkillExtraction(params: { onboardingActive: boolean }): boolean {
2
- return !params.onboardingActive
1
+ import { getBackgroundProcessingConfig, shouldRunAtFrequency } from '../config/background-processing'
2
+
3
+ export function shouldEnqueueSkillExtraction(params: { onboardingActive: boolean; turnCount?: number }): boolean {
4
+ if (params.onboardingActive) return false
5
+ const { skillExtractionFrequency } = getBackgroundProcessingConfig()
6
+ if (typeof params.turnCount === 'number') {
7
+ return shouldRunAtFrequency(params.turnCount, skillExtractionFrequency)
8
+ }
9
+ return true
3
10
  }
@@ -8,10 +8,12 @@ import { isUniqueIndexConflict } from '../db/memory-store.helpers'
8
8
  import type {
9
9
  AddOptions,
10
10
  ExtractedFact,
11
+ MemoryListScalar,
11
12
  MemoryRecord,
12
13
  MemorySearchResult,
13
14
  MemoryType,
14
15
  Message,
16
+ RelationType,
15
17
  } from '../db/memory-types'
16
18
  import { withOrgMemoryLock } from '../redis/org-memory-lock'
17
19
  import { createHelperModelRuntime } from '../runtime/helper-model'
@@ -413,6 +415,19 @@ class MemoryService {
413
415
  return `Agent memory (${agentName}):\n${agentResult}\n\nGlobal org memory:\n${orgResult}`
414
416
  }
415
417
 
418
+ async listOrganizationMemoryRecords(params: {
419
+ orgId: string
420
+ limit?: number
421
+ memoryType?: MemoryType
422
+ metadataEquals?: Record<string, MemoryListScalar>
423
+ metadataNotEquals?: Record<string, MemoryListScalar>
424
+ sort?: 'createdAtAsc' | 'createdAtDesc'
425
+ }): Promise<MemoryRecord[]> {
426
+ const { orgId, ...listOptions } = params
427
+ const orgMemory = this.getOrgMemory(orgId)
428
+ return await orgMemory.list({ scopeId: scopeId(ORG_SCOPE_PREFIX, orgId), ...listOptions })
429
+ }
430
+
416
431
  async getTopMemories(params: { orgId: string; agentName?: string; limit?: number }): Promise<string | undefined> {
417
432
  const orgMemory = this.getOrgMemory(params.orgId)
418
433
  const orgScopeId = scopeId(ORG_SCOPE_PREFIX, params.orgId)
@@ -568,12 +583,14 @@ class MemoryService {
568
583
  memoryType,
569
584
  metadata,
570
585
  importance,
586
+ durability,
571
587
  }: {
572
588
  orgId: string
573
589
  content: string
574
590
  memoryType: MemoryType
575
591
  metadata?: Record<string, unknown>
576
592
  importance?: number
593
+ durability?: MemoryRecord['durability']
577
594
  }): Promise<string> {
578
595
  const orgScopeId = scopeId(ORG_SCOPE_PREFIX, orgId)
579
596
  aiLogger.info`[MEMORY_DEBUG] createOrganizationMemory - orgId: "${orgId}", scopeId: "${orgScopeId}", content preview: "${content.slice(0, 50)}"`
@@ -583,6 +600,7 @@ class MemoryService {
583
600
  scopeId: orgScopeId,
584
601
  memoryType,
585
602
  importance: importance ?? 1,
603
+ durability,
586
604
  metadata: { orgId, ...metadata },
587
605
  })
588
606
  } catch (error) {
@@ -594,6 +612,23 @@ class MemoryService {
594
612
  }
595
613
  }
596
614
 
615
+ async addOrganizationMemoryRelation({
616
+ orgId,
617
+ fromMemoryId,
618
+ toMemoryId,
619
+ relationType,
620
+ confidence,
621
+ }: {
622
+ orgId: string
623
+ fromMemoryId: string
624
+ toMemoryId: string
625
+ relationType: RelationType
626
+ confidence?: number
627
+ }): Promise<void> {
628
+ const memory = this.getOrgMemory(orgId)
629
+ await memory.addRelation(fromMemoryId, toMemoryId, relationType, confidence)
630
+ }
631
+
597
632
  async createAgentMemory({
598
633
  orgId,
599
634
  agentName,
@@ -24,6 +24,7 @@ import type { RecordIdRef } from '../db/record-id'
24
24
  import { recordIdToString } from '../db/record-id'
25
25
  import { TABLES } from '../db/tables'
26
26
  import { enqueueContextCompaction } from '../queues/context-compaction.queue'
27
+ import { enqueueMemoryConsolidation } from '../queues/memory-consolidation.queue'
27
28
  import { enqueuePostChatMemory } from '../queues/post-chat-memory.queue'
28
29
  import { enqueueRecentActivityTitleRefinement } from '../queues/recent-activity-title-refinement.queue'
29
30
  import { enqueueRegularChatMemoryDigest } from '../queues/regular-chat-memory-digest.queue'
@@ -44,6 +45,8 @@ import { CONTEXT_SIZE } from '../runtime/context-compaction-constants'
44
45
  import { createExecutionPlanInstructionSectionCache } from '../runtime/execution-plan'
45
46
  import { mergeInstructionSections } from '../runtime/instruction-sections'
46
47
  import {
48
+ shouldEnqueueMemoryConsolidation,
49
+ shouldEnqueueMemoryExtraction,
47
50
  shouldEnqueueOnboardingPostChatMemory,
48
51
  shouldEnqueueRegularDigestForWorkstream,
49
52
  } from '../runtime/memory-digest-policy'
@@ -1211,6 +1214,7 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
1211
1214
  }
1212
1215
 
1213
1216
  if (allAssistantMessages.length > 0 && shouldProcessPostRunSideEffects) {
1217
+ const turnCount = await workstreamService.incrementTurnCount(workstreamRef)
1214
1218
  const agentMessages = buildAgentHistoryMessages(allAssistantMessages)
1215
1219
  const historyMessagesForMemory = appendCompactionContextToHistoryMessages(
1216
1220
  toHistoryMessages(recentHistory),
@@ -1221,14 +1225,16 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
1221
1225
  const readableUploads = listReadableUploads()
1222
1226
  const attachmentMetadataContext = buildReadableUploadMetadataContext(readableUploads)
1223
1227
  const hasAttachmentContext = Boolean(attachmentMetadataContext)
1224
- if (
1225
- shouldEnqueueOnboardingPostChatMemory({
1226
- onboardingActive,
1227
- userMessageText,
1228
- hasAttachmentContext,
1229
- agentMessageCount: agentMessages.length,
1230
- })
1231
- ) {
1228
+ const shouldExtractMemory = onboardingActive
1229
+ ? shouldEnqueueOnboardingPostChatMemory({
1230
+ onboardingActive,
1231
+ userMessageText,
1232
+ hasAttachmentContext,
1233
+ agentMessageCount: agentMessages.length,
1234
+ })
1235
+ : shouldEnqueueMemoryExtraction({ onboardingActive, turnCount }) && userMessageText.length > 0
1236
+
1237
+ if (shouldExtractMemory) {
1232
1238
  const memoryUserMessage = userMessageText || 'User uploaded attachment(s).'
1233
1239
  await safeEnqueue(
1234
1240
  () =>
@@ -1315,17 +1321,23 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
1315
1321
  }
1316
1322
  }
1317
1323
 
1318
- if (shouldEnqueueRegularDigestForWorkstream({ onboardingActive })) {
1324
+ if (shouldEnqueueRegularDigestForWorkstream({ onboardingActive, turnCount })) {
1319
1325
  await safeEnqueue(() => enqueueRegularChatMemoryDigest({ orgId: orgIdString }), {
1320
1326
  operationName: 'regular chat memory digest enqueue',
1321
1327
  })
1322
1328
  }
1323
1329
 
1324
- if (shouldEnqueueSkillExtraction({ onboardingActive })) {
1330
+ if (shouldEnqueueSkillExtraction({ onboardingActive, turnCount })) {
1325
1331
  await safeEnqueue(() => enqueueSkillExtraction({ orgId: orgIdString }), {
1326
1332
  operationName: 'skill extraction enqueue',
1327
1333
  })
1328
1334
  }
1335
+
1336
+ if (shouldEnqueueMemoryConsolidation({ onboardingActive, turnCount })) {
1337
+ await safeEnqueue(() => enqueueMemoryConsolidation({ scopeId: orgIdString }), {
1338
+ operationName: 'memory consolidation enqueue',
1339
+ })
1340
+ }
1329
1341
  }
1330
1342
 
1331
1343
  if (allAssistantMessages.length > 0) {
@@ -2,10 +2,12 @@ import type { ChatMessage } from '@lota-sdk/shared/schemas/chat-message'
2
2
  import { createUIMessageStream } from 'ai'
3
3
 
4
4
  import { hasApprovalRespondedParts, isApprovalContinuationRequest } from '../runtime/approval-continuation'
5
+ import { wrapResponseWithKeepalive } from '../utils/sse-keepalive'
5
6
  import { prepareWorkstreamRunCore } from './workstream-turn-preparation'
6
7
  import type { WorkstreamTurnParams, WorkstreamApprovalContinuationParams } from './workstream-turn-preparation'
7
8
 
8
9
  export { hasApprovalRespondedParts, isApprovalContinuationRequest }
10
+ export { wrapResponseWithKeepalive }
9
11
 
10
12
  export async function createWorkstreamApprovalContinuationStream(params: WorkstreamApprovalContinuationParams) {
11
13
  const prepared = await prepareWorkstreamRunCore({ ...params, kind: 'approvalContinuation' })
@@ -568,6 +568,48 @@ class WorkstreamService extends BaseService<typeof WorkstreamSchema> {
568
568
  return { workstreams: sliced.map((workstream) => this.normalizeWorkstream(workstream)), hasMore }
569
569
  }
570
570
 
571
+ async listOrganizationWorkstreams(params: {
572
+ orgId: RecordIdRef
573
+ mode?: 'direct' | 'group'
574
+ agentId?: string
575
+ core?: boolean
576
+ includeArchived?: boolean
577
+ }): Promise<NormalizedWorkstream[]> {
578
+ const whereClauses = ['organizationId = $orgId']
579
+ const variables: Record<string, unknown> = { orgId: params.orgId }
580
+
581
+ if (params.mode) {
582
+ whereClauses.push('mode = $mode')
583
+ variables.mode = params.mode
584
+ }
585
+
586
+ if (typeof params.core === 'boolean') {
587
+ whereClauses.push('core = $core')
588
+ variables.core = params.core
589
+ }
590
+
591
+ if (params.agentId) {
592
+ whereClauses.push('agentId = $agentId')
593
+ variables.agentId = params.agentId
594
+ }
595
+
596
+ if (params.includeArchived !== true) {
597
+ whereClauses.push('status = "regular"')
598
+ }
599
+
600
+ const workstreams = await databaseService.queryMany<typeof WorkstreamSchema>(
601
+ new BoundQuery(
602
+ `SELECT * FROM ${TABLES.WORKSTREAM}
603
+ WHERE ${whereClauses.join('\n AND ')}
604
+ ORDER BY createdAt ASC, id ASC`,
605
+ variables,
606
+ ),
607
+ WorkstreamSchema,
608
+ )
609
+
610
+ return workstreams.map((workstream) => this.normalizeWorkstream(workstream))
611
+ }
612
+
571
613
  async getWorkstream(workstreamId: RecordIdRef): Promise<NormalizedWorkstream> {
572
614
  const workstream = await this.getById(workstreamId)
573
615
  return this.normalizeWorkstream(workstream)
@@ -842,6 +884,16 @@ class WorkstreamService extends BaseService<typeof WorkstreamSchema> {
842
884
  return { ...publicWorkstream, workstreamState }
843
885
  }
844
886
 
887
+ async incrementTurnCount(workstreamId: RecordIdRef): Promise<number> {
888
+ const workstreamRef = ensureRecordId(workstreamId, TABLES.WORKSTREAM)
889
+ const result = await databaseService.query<{ turnCount: number }>(surql`
890
+ UPDATE ONLY ${workstreamRef}
891
+ SET turnCount += 1
892
+ RETURN turnCount
893
+ `)
894
+ return result[0]?.turnCount ?? 0
895
+ }
896
+
845
897
  async persistGeneratedTitle(workstreamId: RecordIdRef, title: string): Promise<void> {
846
898
  await this.update(workstreamId, { title, nameGenerated: true })
847
899
  }
@@ -111,6 +111,7 @@ export const WorkstreamSchema = z.object({
111
111
  nameGenerated: z.boolean().optional().default(false),
112
112
  isCompacting: z.boolean().optional(),
113
113
  state: z.unknown().optional(),
114
+ turnCount: z.number().int().optional().default(0),
114
115
  createdAt: z.coerce.date(),
115
116
  updatedAt: z.coerce.date(),
116
117
  userId: z.any(), // RecordId
@@ -1,5 +1,8 @@
1
1
  import { bifrostChatModel } from '../bifrost/bifrost'
2
- import { OPENROUTER_WEB_RESEARCH_MODEL_ID } from '../config/model-constants'
2
+ import {
3
+ OPENROUTER_MEDIUM_REASONING_PROVIDER_OPTIONS,
4
+ OPENROUTER_WEB_RESEARCH_MODEL_ID,
5
+ } from '../config/model-constants'
3
6
  import { createDelegatedAgentTool } from '../system-agents/delegated-agent-factory'
4
7
  import { RESEARCHER_PROMPT } from '../system-agents/researcher.agent'
5
8
  import { fetchWebpageTool } from './fetch-webpage.tool'
@@ -10,6 +13,7 @@ export const researchTopicTool = createDelegatedAgentTool({
10
13
  description:
11
14
  'Delegate a research task to a dedicated research agent that searches the web, fetches pages, and returns a synthesized markdown report. Call multiple instances in parallel for broad research across different topics.',
12
15
  model: bifrostChatModel(OPENROUTER_WEB_RESEARCH_MODEL_ID),
16
+ providerOptions: OPENROUTER_MEDIUM_REASONING_PROVIDER_OPTIONS,
13
17
  instructions: RESEARCHER_PROMPT,
14
18
  tools: { searchWeb: searchWebTool.create(), fetchWebpage: fetchWebpageTool.create() },
15
19
  })
@@ -0,0 +1,40 @@
1
+ const KEEPALIVE_COMMENT = new TextEncoder().encode(': keepalive\n\n')
2
+ const DEFAULT_KEEPALIVE_INTERVAL_MS = 20_000
3
+
4
+ /**
5
+ * Wraps an SSE Response body with periodic keepalive comments.
6
+ * SSE comments (`: keepalive\n\n`) are ignored by standard SSE parsers,
7
+ * so no client changes are needed.
8
+ */
9
+ export function wrapResponseWithKeepalive(response: Response, intervalMs = DEFAULT_KEEPALIVE_INTERVAL_MS): Response {
10
+ const body = response.body
11
+ if (!body) return response
12
+
13
+ let intervalHandle: ReturnType<typeof setInterval> | null = null
14
+
15
+ const transformed = body.pipeThrough(
16
+ new TransformStream<Uint8Array, Uint8Array>({
17
+ start(controller) {
18
+ intervalHandle = setInterval(() => {
19
+ try {
20
+ controller.enqueue(KEEPALIVE_COMMENT)
21
+ } catch {
22
+ if (intervalHandle) clearInterval(intervalHandle)
23
+ }
24
+ }, intervalMs)
25
+ },
26
+ transform(chunk, controller) {
27
+ controller.enqueue(chunk)
28
+ },
29
+ flush() {
30
+ if (intervalHandle) clearInterval(intervalHandle)
31
+ },
32
+ }),
33
+ )
34
+
35
+ return new Response(transformed, {
36
+ headers: response.headers,
37
+ status: response.status,
38
+ statusText: response.statusText,
39
+ })
40
+ }