@lota-sdk/core 0.1.5

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 (153) hide show
  1. package/infrastructure/schema/00_workstream.surql +55 -0
  2. package/infrastructure/schema/01_memory.surql +47 -0
  3. package/infrastructure/schema/02_execution_plan.surql +62 -0
  4. package/infrastructure/schema/03_learned_skill.surql +32 -0
  5. package/infrastructure/schema/04_runtime_bootstrap.surql +8 -0
  6. package/package.json +128 -0
  7. package/src/ai/definitions.ts +308 -0
  8. package/src/bifrost/bifrost.ts +256 -0
  9. package/src/config/agent-defaults.ts +99 -0
  10. package/src/config/constants.ts +33 -0
  11. package/src/config/env-shapes.ts +122 -0
  12. package/src/config/logger.ts +29 -0
  13. package/src/config/model-constants.ts +31 -0
  14. package/src/config/search.ts +17 -0
  15. package/src/config/workstream-defaults.ts +68 -0
  16. package/src/db/base.service.ts +55 -0
  17. package/src/db/cursor-pagination.ts +73 -0
  18. package/src/db/memory-query-builder.ts +207 -0
  19. package/src/db/memory-store.helpers.ts +118 -0
  20. package/src/db/memory-store.rows.ts +29 -0
  21. package/src/db/memory-store.ts +974 -0
  22. package/src/db/memory-types.ts +193 -0
  23. package/src/db/memory.ts +505 -0
  24. package/src/db/record-id.ts +78 -0
  25. package/src/db/service.ts +932 -0
  26. package/src/db/startup.ts +152 -0
  27. package/src/db/tables.ts +20 -0
  28. package/src/document/org-document-chunking.ts +224 -0
  29. package/src/document/parsing.ts +40 -0
  30. package/src/embeddings/provider.ts +76 -0
  31. package/src/index.ts +302 -0
  32. package/src/queues/context-compaction.queue.ts +82 -0
  33. package/src/queues/document-processor.queue.ts +118 -0
  34. package/src/queues/memory-consolidation.queue.ts +65 -0
  35. package/src/queues/post-chat-memory.queue.ts +128 -0
  36. package/src/queues/recent-activity-title-refinement.queue.ts +69 -0
  37. package/src/queues/regular-chat-memory-digest.config.ts +12 -0
  38. package/src/queues/regular-chat-memory-digest.queue.ts +73 -0
  39. package/src/queues/skill-extraction.config.ts +9 -0
  40. package/src/queues/skill-extraction.queue.ts +62 -0
  41. package/src/redis/connection.ts +176 -0
  42. package/src/redis/index.ts +30 -0
  43. package/src/redis/org-memory-lock.ts +43 -0
  44. package/src/redis/redis-lease-lock.ts +158 -0
  45. package/src/runtime/agent-contract.ts +1 -0
  46. package/src/runtime/agent-prompt-context.ts +119 -0
  47. package/src/runtime/agent-runtime-policy.ts +192 -0
  48. package/src/runtime/agent-stream-helpers.ts +117 -0
  49. package/src/runtime/agent-types.ts +22 -0
  50. package/src/runtime/approval-continuation.ts +16 -0
  51. package/src/runtime/chat-attachments.ts +46 -0
  52. package/src/runtime/chat-message.ts +10 -0
  53. package/src/runtime/chat-request-routing.ts +21 -0
  54. package/src/runtime/chat-run-orchestration.ts +25 -0
  55. package/src/runtime/chat-run-registry.ts +20 -0
  56. package/src/runtime/chat-types.ts +18 -0
  57. package/src/runtime/context-compaction-constants.ts +11 -0
  58. package/src/runtime/context-compaction-runtime.ts +86 -0
  59. package/src/runtime/context-compaction.ts +909 -0
  60. package/src/runtime/execution-plan.ts +59 -0
  61. package/src/runtime/helper-model.ts +405 -0
  62. package/src/runtime/indexed-repositories-policy.ts +28 -0
  63. package/src/runtime/instruction-sections.ts +8 -0
  64. package/src/runtime/llm-content.ts +71 -0
  65. package/src/runtime/memory-block.ts +264 -0
  66. package/src/runtime/memory-digest-policy.ts +14 -0
  67. package/src/runtime/memory-format.ts +8 -0
  68. package/src/runtime/memory-pipeline.ts +570 -0
  69. package/src/runtime/memory-prompts-fact.ts +47 -0
  70. package/src/runtime/memory-prompts-parse.ts +3 -0
  71. package/src/runtime/memory-prompts-update.ts +37 -0
  72. package/src/runtime/memory-scope.ts +43 -0
  73. package/src/runtime/plugin-types.ts +10 -0
  74. package/src/runtime/retrieval-adapters.ts +25 -0
  75. package/src/runtime/retrieval-pipeline.ts +3 -0
  76. package/src/runtime/runtime-extensions.ts +154 -0
  77. package/src/runtime/skill-extraction-policy.ts +3 -0
  78. package/src/runtime/team-consultation-orchestrator.ts +245 -0
  79. package/src/runtime/team-consultation-prompts.ts +32 -0
  80. package/src/runtime/title-helpers.ts +12 -0
  81. package/src/runtime/turn-lifecycle.ts +28 -0
  82. package/src/runtime/workstream-chat-helpers.ts +187 -0
  83. package/src/runtime/workstream-routing-policy.ts +301 -0
  84. package/src/runtime/workstream-state.ts +261 -0
  85. package/src/services/attachment.service.ts +159 -0
  86. package/src/services/chat-attachments.service.ts +17 -0
  87. package/src/services/chat-run-registry.service.ts +3 -0
  88. package/src/services/context-compaction-runtime.ts +13 -0
  89. package/src/services/context-compaction.service.ts +115 -0
  90. package/src/services/document-chunk.service.ts +141 -0
  91. package/src/services/execution-plan.service.ts +890 -0
  92. package/src/services/learned-skill.service.ts +328 -0
  93. package/src/services/memory-assessment.service.ts +43 -0
  94. package/src/services/memory.service.ts +807 -0
  95. package/src/services/memory.utils.ts +84 -0
  96. package/src/services/mutating-approval.service.ts +110 -0
  97. package/src/services/recent-activity-title.service.ts +74 -0
  98. package/src/services/recent-activity.service.ts +397 -0
  99. package/src/services/workstream-change-tracker.service.ts +313 -0
  100. package/src/services/workstream-message.service.ts +283 -0
  101. package/src/services/workstream-title.service.ts +58 -0
  102. package/src/services/workstream-turn-preparation.ts +1340 -0
  103. package/src/services/workstream-turn.ts +37 -0
  104. package/src/services/workstream.service.ts +854 -0
  105. package/src/services/workstream.types.ts +118 -0
  106. package/src/storage/attachment-parser.ts +101 -0
  107. package/src/storage/attachment-storage.service.ts +391 -0
  108. package/src/storage/attachments.types.ts +11 -0
  109. package/src/storage/attachments.utils.ts +58 -0
  110. package/src/storage/generated-document-storage.service.ts +55 -0
  111. package/src/system-agents/agent-result.ts +27 -0
  112. package/src/system-agents/context-compacter.agent.ts +46 -0
  113. package/src/system-agents/delegated-agent-factory.ts +177 -0
  114. package/src/system-agents/helper-agent-options.ts +20 -0
  115. package/src/system-agents/memory-reranker.agent.ts +38 -0
  116. package/src/system-agents/memory.agent.ts +58 -0
  117. package/src/system-agents/recent-activity-title-refiner.agent.ts +53 -0
  118. package/src/system-agents/regular-chat-memory-digest.agent.ts +75 -0
  119. package/src/system-agents/researcher.agent.ts +34 -0
  120. package/src/system-agents/skill-extractor.agent.ts +88 -0
  121. package/src/system-agents/skill-manager.agent.ts +80 -0
  122. package/src/system-agents/title-generator.agent.ts +42 -0
  123. package/src/system-agents/workstream-tracker.agent.ts +58 -0
  124. package/src/tools/execution-plan.tool.ts +163 -0
  125. package/src/tools/fetch-webpage.tool.ts +132 -0
  126. package/src/tools/firecrawl-client.ts +12 -0
  127. package/src/tools/memory-block.tool.ts +55 -0
  128. package/src/tools/read-file-parts.tool.ts +80 -0
  129. package/src/tools/remember-memory.tool.ts +85 -0
  130. package/src/tools/research-topic.tool.ts +15 -0
  131. package/src/tools/search-tools.ts +55 -0
  132. package/src/tools/search-web.tool.ts +175 -0
  133. package/src/tools/team-think.tool.ts +125 -0
  134. package/src/tools/tool-contract.ts +21 -0
  135. package/src/tools/user-questions.tool.ts +18 -0
  136. package/src/utils/async.ts +50 -0
  137. package/src/utils/date-time.ts +34 -0
  138. package/src/utils/error.ts +10 -0
  139. package/src/utils/errors.ts +28 -0
  140. package/src/utils/hono-error-handler.ts +71 -0
  141. package/src/utils/string.ts +51 -0
  142. package/src/workers/bootstrap.ts +44 -0
  143. package/src/workers/memory-consolidation.worker.ts +318 -0
  144. package/src/workers/regular-chat-memory-digest.helpers.ts +100 -0
  145. package/src/workers/regular-chat-memory-digest.runner.ts +363 -0
  146. package/src/workers/regular-chat-memory-digest.worker.ts +22 -0
  147. package/src/workers/skill-extraction.runner.ts +331 -0
  148. package/src/workers/skill-extraction.worker.ts +22 -0
  149. package/src/workers/utils/repo-indexer-chunker.ts +331 -0
  150. package/src/workers/utils/repo-structure-extractor.ts +645 -0
  151. package/src/workers/utils/repomix-process-concurrency.ts +65 -0
  152. package/src/workers/utils/sandbox-error.ts +5 -0
  153. package/src/workers/worker-utils.ts +182 -0
@@ -0,0 +1,84 @@
1
+ import { MEMORY } from '../config/constants'
2
+ import { VECTOR_SEARCH_OVERFETCH_MULTIPLIER } from '../config/search'
3
+ import type { MemorySearchResult } from '../db/memory-types'
4
+ import { resolveCandidateLimit } from '../runtime/retrieval-pipeline'
5
+ import type { MemoryRerankOutput } from './memory.service'
6
+
7
+ export function getCandidateLimit(limit: number): number {
8
+ return resolveCandidateLimit({
9
+ limit,
10
+ multiplier: VECTOR_SEARCH_OVERFETCH_MULTIPLIER,
11
+ minimum: MEMORY.DEFAULT_CANDIDATE_LIMIT,
12
+ })
13
+ }
14
+
15
+ export function formatMemoryResults(results: MemorySearchResult[]): string {
16
+ if (results.length === 0) return 'No stored memories.'
17
+
18
+ const normalize = (value: string) => {
19
+ const trimmed = value.replace(/\s+/g, ' ').trim()
20
+ if (trimmed.length <= 400) return trimmed
21
+ return `${trimmed.slice(0, 400)}...`
22
+ }
23
+
24
+ return results
25
+ .map((item) => {
26
+ const line = `- ${normalize(item.content)}`
27
+ const annotations: string[] = []
28
+ if (item.contradictions?.length) {
29
+ for (const c of item.contradictions) {
30
+ annotations.push(` [!] Contradicted by: ${normalize(c)}`)
31
+ }
32
+ }
33
+ const related = item.metadata.relatedContext
34
+ if (Array.isArray(related) && related.length > 0) {
35
+ for (const ctx of related.slice(0, 3)) {
36
+ annotations.push(` [~] Related: ${normalize(String(ctx))}`)
37
+ }
38
+ }
39
+ return annotations.length > 0 ? `${line}\n${annotations.join('\n')}` : line
40
+ })
41
+ .join('\n')
42
+ }
43
+
44
+ export function formatRerankedResults(
45
+ reranked: MemoryRerankOutput | null,
46
+ candidates: MemorySearchResult[],
47
+ limit: number,
48
+ ): string {
49
+ if (!reranked || reranked.sections.length === 0) {
50
+ return formatMemoryResults(candidates.slice(0, limit))
51
+ }
52
+
53
+ const candidateById = new Map(candidates.map((item) => [item.id, item]))
54
+ const used = new Set<string>()
55
+ const sections: string[] = []
56
+ let total = 0
57
+
58
+ for (const section of reranked.sections) {
59
+ if (total >= limit) break
60
+
61
+ const lines: string[] = []
62
+ for (const item of section.items) {
63
+ const candidate = candidateById.get(item.id)
64
+ if (!candidate || used.has(candidate.id)) continue
65
+ used.add(candidate.id)
66
+ total += 1
67
+ const reason = item.relevance ? ` — ${item.relevance}` : ''
68
+ const trimmed = candidate.content.replace(/\s+/g, ' ').trim()
69
+ const content = trimmed.length <= 400 ? trimmed : `${trimmed.slice(0, 400)}...`
70
+ lines.push(`- ${content}${reason}`)
71
+ if (total >= limit) break
72
+ }
73
+
74
+ if (lines.length > 0) {
75
+ sections.push(`${section.title}:\n${lines.join('\n')}`)
76
+ }
77
+ }
78
+
79
+ if (sections.length === 0) {
80
+ return formatMemoryResults(candidates.slice(0, limit))
81
+ }
82
+
83
+ return sections.join('\n\n')
84
+ }
@@ -0,0 +1,110 @@
1
+ import type { RecordIdRef } from '../db/record-id'
2
+ import { ensureRecordId } from '../db/record-id'
3
+ import { TABLES } from '../db/tables'
4
+ import { extractMessageText } from '../runtime/workstream-chat-helpers'
5
+ import { workstreamMessageService } from './workstream-message.service'
6
+
7
+ const APPROVAL_VERIFICATION_MESSAGE_WINDOW = 20
8
+
9
+ export type VerifyMutatingApproval = (params: {
10
+ workstreamId: string
11
+ approvalReason: string
12
+ approvalToken: string
13
+ approvalMessageId?: string
14
+ }) => Promise<{ ok: true } | { ok: false; reason: string }>
15
+
16
+ function extractQuotedValue(value: string): string {
17
+ const trimmed = value.trim()
18
+ if (
19
+ (trimmed.startsWith('"') && trimmed.endsWith('"')) ||
20
+ (trimmed.startsWith("'") && trimmed.endsWith("'")) ||
21
+ (trimmed.startsWith('`') && trimmed.endsWith('`'))
22
+ ) {
23
+ return trimmed.slice(1, -1).trim()
24
+ }
25
+ return trimmed
26
+ }
27
+
28
+ function extractApprovalFieldsFromMessage(messageText: string): { token?: string; reason?: string } {
29
+ const lines = messageText
30
+ .split('\n')
31
+ .map((line) => line.trim())
32
+ .filter((line) => line.length > 0)
33
+
34
+ let token: string | undefined
35
+ let reason: string | undefined
36
+
37
+ for (const line of lines) {
38
+ const tokenMatch = /^approval\s*token\s*:\s*(.+)$/i.exec(line)
39
+ if (tokenMatch?.[1]) token = extractQuotedValue(tokenMatch[1])
40
+
41
+ const reasonMatch = /^approval\s*reason\s*:\s*(.+)$/i.exec(line)
42
+ if (reasonMatch?.[1]) reason = extractQuotedValue(reasonMatch[1])
43
+ }
44
+
45
+ if (!token || !reason) {
46
+ const tokenJsonMatch = /["']approvalToken["']\s*:\s*["']([^"']+)["']/i.exec(messageText)
47
+ const reasonJsonMatch = /["']approvalReason["']\s*:\s*["']([^"']+)["']/i.exec(messageText)
48
+ if (!token && tokenJsonMatch?.[1]) token = tokenJsonMatch[1].trim()
49
+ if (!reason && reasonJsonMatch?.[1]) reason = reasonJsonMatch[1].trim()
50
+ }
51
+
52
+ return { token, reason }
53
+ }
54
+
55
+ function messageContainsExactApproval(messageText: string, approvalToken: string, approvalReason: string): boolean {
56
+ const extracted = extractApprovalFieldsFromMessage(messageText)
57
+ if (!extracted.token || !extracted.reason) return false
58
+ return extracted.token === approvalToken && extracted.reason === approvalReason
59
+ }
60
+
61
+ async function verifyMutatingApprovalForWorkstream(
62
+ workstreamId: RecordIdRef,
63
+ params: { approvalReason: string; approvalToken: string; approvalMessageId?: string },
64
+ ): Promise<{ ok: true } | { ok: false; reason: string }> {
65
+ const token = extractQuotedValue(params.approvalToken)
66
+ const reason = extractQuotedValue(params.approvalReason)
67
+ const approvalMessageId = params.approvalMessageId?.trim()
68
+ if (!token || !reason) {
69
+ return { ok: false, reason: 'Mutating approval metadata is incomplete.' }
70
+ }
71
+ if (params.approvalMessageId !== undefined && !approvalMessageId) {
72
+ return { ok: false, reason: 'approvalMessageId cannot be empty when provided.' }
73
+ }
74
+
75
+ const recentMessages = await workstreamMessageService.listRecentMessages(
76
+ workstreamId,
77
+ APPROVAL_VERIFICATION_MESSAGE_WINDOW,
78
+ )
79
+ const userMessages = recentMessages.filter((message) => message.role === 'user')
80
+ if (userMessages.length === 0) {
81
+ return { ok: false, reason: 'No user message history available to verify mutating approval.' }
82
+ }
83
+
84
+ const latestUserMessage = userMessages.at(-1)
85
+ if (!latestUserMessage) {
86
+ return { ok: false, reason: 'Latest user message was not found for mutating approval verification.' }
87
+ }
88
+
89
+ if (approvalMessageId && approvalMessageId !== latestUserMessage.id) {
90
+ return {
91
+ ok: false,
92
+ reason:
93
+ 'approvalMessageId must reference the latest user message for mutating approval verification; stale approvals are not accepted.',
94
+ }
95
+ }
96
+
97
+ if (!messageContainsExactApproval(extractMessageText(latestUserMessage), token, reason)) {
98
+ return {
99
+ ok: false,
100
+ reason:
101
+ 'Referenced approval message must contain exact approval fields: "Approval Token: <token>" and "Approval Reason: <reason>".',
102
+ }
103
+ }
104
+
105
+ return { ok: true }
106
+ }
107
+
108
+ export const verifyMutatingApproval: VerifyMutatingApproval = async (params) => {
109
+ return await verifyMutatingApprovalForWorkstream(ensureRecordId(params.workstreamId, TABLES.WORKSTREAM), params)
110
+ }
@@ -0,0 +1,74 @@
1
+ import { createHelperModelRuntime } from '../runtime/helper-model'
2
+ import {
3
+ createRecentActivityTitleRefinerAgent,
4
+ recentActivityTitleRefinerPrompt,
5
+ } from '../system-agents/recent-activity-title-refiner.agent'
6
+ import { compactWhitespace } from '../utils/string'
7
+ import { recentActivityService } from './recent-activity.service'
8
+
9
+ const RECENT_ACTIVITY_TITLE_TIMEOUT_MS = 60_000
10
+
11
+ function normalizeTitle(value: string): string {
12
+ const normalized = compactWhitespace(value)
13
+ .replace(/^["'`]+|["'`]+$/g, '')
14
+ .replace(/[.!?,;:]+$/g, '')
15
+ return normalized.length <= 80 ? normalized : normalized.slice(0, 80).trim()
16
+ }
17
+
18
+ function buildRefinementPromptInput(
19
+ candidate: Awaited<ReturnType<typeof recentActivityService.getRefinementCandidate>>,
20
+ ) {
21
+ if (!candidate) return null
22
+
23
+ const metadata = candidate.metadata
24
+ const lines = [
25
+ `sourceLabel=${candidate.sourceLabel}`,
26
+ `systemTitle=${candidate.systemTitle}`,
27
+ metadata.agentName ? `agentName=${metadata.agentName}` : null,
28
+ metadata.workstreamTitle ? `workstreamTitle=${metadata.workstreamTitle}` : null,
29
+ metadata.userMessageText ? `userMessage=${metadata.userMessageText}` : null,
30
+ metadata.assistantSummary ? `assistantSummary=${metadata.assistantSummary}` : null,
31
+ ].filter((line): line is string => Boolean(line))
32
+
33
+ if (lines.length === 0) return null
34
+ return lines.join('\n')
35
+ }
36
+
37
+ class RecentActivityTitleService {
38
+ helperRuntime: ReturnType<typeof createHelperModelRuntime> = createHelperModelRuntime()
39
+
40
+ async refineRecentActivityTitle(activityId: string): Promise<void> {
41
+ const candidate = await recentActivityService.getRefinementCandidate(activityId)
42
+ if (!candidate || candidate.kind !== 'chat.turn.completed') {
43
+ return
44
+ }
45
+
46
+ const promptInput = buildRefinementPromptInput(candidate)
47
+ if (!promptInput) {
48
+ return
49
+ }
50
+
51
+ const refinedTitle = normalizeTitle(
52
+ await this.helperRuntime.generateHelperText({
53
+ tag: 'recent-activity-title-refinement',
54
+ createAgent: createRecentActivityTitleRefinerAgent,
55
+ defaultSystemPrompt: recentActivityTitleRefinerPrompt,
56
+ timeoutMs: RECENT_ACTIVITY_TITLE_TIMEOUT_MS,
57
+ messages: [{ role: 'user', content: promptInput }],
58
+ }),
59
+ )
60
+ if (
61
+ !recentActivityService.isChiefTitleUseful({
62
+ currentTitle: candidate.title,
63
+ systemTitle: candidate.systemTitle,
64
+ candidateTitle: refinedTitle,
65
+ })
66
+ ) {
67
+ return
68
+ }
69
+
70
+ await recentActivityService.refineTitle({ activityId, title: refinedTitle })
71
+ }
72
+ }
73
+
74
+ export const recentActivityTitleService = new RecentActivityTitleService()
@@ -0,0 +1,397 @@
1
+ import { createHash } from 'node:crypto'
2
+
3
+ import {
4
+ RecentActivityDeepLinkSchema,
5
+ RecentActivityEventInputSchema,
6
+ RecentActivityEventSchema,
7
+ RecentActivityMetadataSchema,
8
+ RecentActivitySchema,
9
+ RecentActivitySourceSchema,
10
+ RecentActivityTitleSourceSchema,
11
+ } from '@lota-sdk/shared/schemas/recent-activity'
12
+ import type {
13
+ RecentActivity,
14
+ RecentActivityEvent,
15
+ RecentActivityEventInput,
16
+ RecentActivitySource,
17
+ } from '@lota-sdk/shared/schemas/recent-activity'
18
+ import { RecordId } from 'surrealdb'
19
+ import { z } from 'zod'
20
+
21
+ import { ensureRecordId, recordIdToString } from '../db/record-id'
22
+ import type { RecordIdInput, RecordIdRef } from '../db/record-id'
23
+ import { databaseService } from '../db/service'
24
+ import { TABLES } from '../db/tables'
25
+ import { toIsoDateTimeString, toOptionalIsoDateTimeString } from '../utils/date-time'
26
+ import { compactWhitespace } from '../utils/string'
27
+
28
+ const RecentActivityEventRowSchema = z.object({
29
+ id: z.unknown(),
30
+ organizationId: z.unknown(),
31
+ userId: z.unknown(),
32
+ sourceEventId: z.string(),
33
+ source: RecentActivitySourceSchema,
34
+ kind: RecentActivityEventInputSchema.shape.kind,
35
+ targetKind: RecentActivityEventInputSchema.shape.targetKind,
36
+ targetId: z.string().optional(),
37
+ mergeKey: z.string(),
38
+ title: z.string(),
39
+ sourceLabel: z.string(),
40
+ deepLink: RecentActivityDeepLinkSchema,
41
+ metadata: RecentActivityMetadataSchema.optional(),
42
+ occurredAt: z.union([z.string(), z.date(), z.number()]),
43
+ createdAt: z.union([z.string(), z.date(), z.number()]),
44
+ })
45
+
46
+ const RecentActivityRowSchema = z.object({
47
+ id: z.unknown(),
48
+ organizationId: z.unknown(),
49
+ userId: z.unknown(),
50
+ mergeKey: z.string(),
51
+ kind: RecentActivityEventInputSchema.shape.kind,
52
+ targetKind: RecentActivityEventInputSchema.shape.targetKind,
53
+ targetId: z.string().optional(),
54
+ title: z.string(),
55
+ systemTitle: z.string(),
56
+ titleSource: RecentActivityTitleSourceSchema,
57
+ sourceLabel: z.string(),
58
+ deepLink: RecentActivityDeepLinkSchema,
59
+ metadata: RecentActivityMetadataSchema.optional(),
60
+ latestEventId: z.unknown().optional(),
61
+ latestSourceEventId: z.string().optional(),
62
+ latestEventAt: z.union([z.string(), z.date(), z.number()]),
63
+ titleRefinedAt: z.union([z.string(), z.date(), z.number()]).optional(),
64
+ createdAt: z.union([z.string(), z.date(), z.number()]),
65
+ updatedAt: z.union([z.string(), z.date(), z.number()]),
66
+ })
67
+
68
+ type RecentActivityEventRow = z.infer<typeof RecentActivityEventRowSchema>
69
+ type RecentActivityRow = z.infer<typeof RecentActivityRowSchema>
70
+
71
+ function compactRecord(value: Record<string, unknown>): Record<string, unknown> {
72
+ return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== null && entry !== undefined))
73
+ }
74
+
75
+ function clampText(value: string, maxLength: number): string {
76
+ const normalized = compactWhitespace(value)
77
+ if (normalized.length <= maxLength) return normalized
78
+ return normalized.slice(0, maxLength).trim()
79
+ }
80
+
81
+ function buildDeterministicRecordId(table: string, key: string): RecordId {
82
+ const digest = createHash('sha256').update(key).digest('hex')
83
+ return new RecordId(table, digest)
84
+ }
85
+
86
+ function shouldKeepExistingChiefTitle(existing: RecentActivityRow | null): boolean {
87
+ return existing?.titleSource === 'chief' && compactWhitespace(existing.title).length > 0
88
+ }
89
+
90
+ function buildRecentActivityAreaKey(
91
+ row: Pick<RecentActivityRow, 'deepLink' | 'kind' | 'mergeKey' | 'metadata'>,
92
+ ): string {
93
+ const target = row.deepLink.search
94
+
95
+ if (target.docId) return `document:${target.docId}`
96
+ if (target.chat) return `workstream:${target.chat}`
97
+ if (target.agent) return `agent:${target.agent}`
98
+
99
+ const normalizedWebsiteUrl = row.metadata?.normalizedWebsiteUrl
100
+ if (row.kind === 'website-intelligence.viewed' && normalizedWebsiteUrl) {
101
+ return `website:${compactWhitespace(normalizedWebsiteUrl).toLowerCase()}`
102
+ }
103
+
104
+ if (row.kind === 'website-intelligence.viewed') return 'website-intelligence'
105
+
106
+ return row.mergeKey
107
+ }
108
+
109
+ class RecentActivityService {
110
+ private toPublicEvent(row: RecentActivityEventRow): RecentActivityEvent {
111
+ return RecentActivityEventSchema.parse({
112
+ id: recordIdToString(
113
+ ensureRecordId(row.id as RecordIdInput, TABLES.RECENT_ACTIVITY_EVENT),
114
+ TABLES.RECENT_ACTIVITY_EVENT,
115
+ ),
116
+ sourceEventId: row.sourceEventId,
117
+ source: row.source,
118
+ kind: row.kind,
119
+ targetKind: row.targetKind,
120
+ targetId: row.targetId,
121
+ mergeKey: row.mergeKey,
122
+ title: row.title,
123
+ sourceLabel: row.sourceLabel,
124
+ deepLink: row.deepLink,
125
+ ...(row.metadata ? { metadata: row.metadata } : {}),
126
+ occurredAt: toIsoDateTimeString(row.occurredAt),
127
+ createdAt: toIsoDateTimeString(row.createdAt),
128
+ })
129
+ }
130
+
131
+ private toPublicItem(row: RecentActivityRow): RecentActivity {
132
+ return RecentActivitySchema.parse({
133
+ id: recordIdToString(ensureRecordId(row.id as RecordIdInput, TABLES.RECENT_ACTIVITY), TABLES.RECENT_ACTIVITY),
134
+ kind: row.kind,
135
+ targetKind: row.targetKind,
136
+ targetId: row.targetId,
137
+ mergeKey: row.mergeKey,
138
+ title: row.title,
139
+ systemTitle: row.systemTitle,
140
+ titleSource: row.titleSource,
141
+ sourceLabel: row.sourceLabel,
142
+ deepLink: row.deepLink,
143
+ ...(row.metadata ? { metadata: row.metadata } : {}),
144
+ latestEventAt: toIsoDateTimeString(row.latestEventAt),
145
+ titleRefinedAt: toOptionalIsoDateTimeString(row.titleRefinedAt),
146
+ createdAt: toIsoDateTimeString(row.createdAt),
147
+ updatedAt: toIsoDateTimeString(row.updatedAt),
148
+ })
149
+ }
150
+
151
+ private sanitizeEvent(input: RecentActivityEventInput): RecentActivityEventInput {
152
+ return {
153
+ ...input,
154
+ sourceEventId: clampText(input.sourceEventId, 200),
155
+ ...(input.targetId ? { targetId: clampText(input.targetId, 200) } : {}),
156
+ mergeKey: clampText(input.mergeKey, 200),
157
+ title: clampText(input.title, 140),
158
+ sourceLabel: clampText(input.sourceLabel, 40),
159
+ ...(input.metadata ? { metadata: RecentActivityMetadataSchema.parse(input.metadata) } : {}),
160
+ }
161
+ }
162
+
163
+ async recordEvents(params: {
164
+ orgId: RecordIdRef
165
+ userId: RecordIdRef
166
+ source: RecentActivitySource
167
+ events: RecentActivityEventInput[]
168
+ }): Promise<RecentActivity[]> {
169
+ await databaseService.connect()
170
+
171
+ const items: RecentActivity[] = []
172
+ for (const candidate of params.events) {
173
+ const recorded = await this.recordEvent({
174
+ orgId: params.orgId,
175
+ userId: params.userId,
176
+ source: params.source,
177
+ event: candidate,
178
+ })
179
+ items.push(recorded.item)
180
+ }
181
+
182
+ return items
183
+ }
184
+
185
+ async recordEvent(params: {
186
+ orgId: RecordIdRef
187
+ userId: RecordIdRef
188
+ source: RecentActivitySource
189
+ event: RecentActivityEventInput
190
+ }): Promise<{ event: RecentActivityEvent; item: RecentActivity }> {
191
+ await databaseService.connect()
192
+
193
+ const parsedEvent = this.sanitizeEvent(RecentActivityEventInputSchema.parse(params.event))
194
+ const orgIdString = recordIdToString(params.orgId, TABLES.ORGANIZATION)
195
+ const userIdString = recordIdToString(params.userId, TABLES.USER)
196
+ const eventRecordId = buildDeterministicRecordId(
197
+ TABLES.RECENT_ACTIVITY_EVENT,
198
+ `${orgIdString}:${userIdString}:${parsedEvent.sourceEventId}`,
199
+ )
200
+ const recentRecordId = buildDeterministicRecordId(
201
+ TABLES.RECENT_ACTIVITY,
202
+ `${orgIdString}:${userIdString}:${parsedEvent.mergeKey}`,
203
+ )
204
+ const occurredAt = parsedEvent.occurredAt ? new Date(parsedEvent.occurredAt) : new Date()
205
+ const existingRecent = await databaseService.findOne(
206
+ TABLES.RECENT_ACTIVITY,
207
+ { id: recentRecordId },
208
+ RecentActivityRowSchema,
209
+ )
210
+
211
+ const eventRow = await databaseService.upsert(
212
+ TABLES.RECENT_ACTIVITY_EVENT,
213
+ eventRecordId,
214
+ compactRecord({
215
+ organizationId: params.orgId,
216
+ userId: params.userId,
217
+ sourceEventId: parsedEvent.sourceEventId,
218
+ source: params.source,
219
+ kind: parsedEvent.kind,
220
+ targetKind: parsedEvent.targetKind,
221
+ targetId: parsedEvent.targetId,
222
+ mergeKey: parsedEvent.mergeKey,
223
+ title: parsedEvent.title,
224
+ sourceLabel: parsedEvent.sourceLabel,
225
+ deepLink: parsedEvent.deepLink,
226
+ metadata: parsedEvent.metadata,
227
+ occurredAt,
228
+ }),
229
+ RecentActivityEventRowSchema,
230
+ { mutation: 'merge' },
231
+ )
232
+
233
+ const keepExistingChiefTitle = shouldKeepExistingChiefTitle(existingRecent)
234
+ const visibleTitle = keepExistingChiefTitle && existingRecent ? existingRecent.title : parsedEvent.title
235
+ const recentRow = await databaseService.upsert(
236
+ TABLES.RECENT_ACTIVITY,
237
+ recentRecordId,
238
+ compactRecord({
239
+ organizationId: params.orgId,
240
+ userId: params.userId,
241
+ mergeKey: parsedEvent.mergeKey,
242
+ kind: parsedEvent.kind,
243
+ targetKind: parsedEvent.targetKind,
244
+ targetId: parsedEvent.targetId,
245
+ title: visibleTitle,
246
+ systemTitle: parsedEvent.title,
247
+ titleSource: keepExistingChiefTitle ? 'chief' : 'system',
248
+ sourceLabel: parsedEvent.sourceLabel,
249
+ deepLink: parsedEvent.deepLink,
250
+ metadata: parsedEvent.metadata,
251
+ latestEventId: eventRecordId,
252
+ latestSourceEventId: parsedEvent.sourceEventId,
253
+ latestEventAt: occurredAt,
254
+ }),
255
+ RecentActivityRowSchema,
256
+ { mutation: 'merge' },
257
+ )
258
+
259
+ return { event: this.toPublicEvent(eventRow), item: this.toPublicItem(recentRow) }
260
+ }
261
+
262
+ async listRecentActivities(params: {
263
+ orgId: RecordIdRef
264
+ userId: RecordIdRef
265
+ limit?: number
266
+ }): Promise<RecentActivity[]> {
267
+ await databaseService.connect()
268
+ const limit = params.limit ?? 5
269
+ const rows = await databaseService.findMany(
270
+ TABLES.RECENT_ACTIVITY,
271
+ { organizationId: params.orgId, userId: params.userId },
272
+ RecentActivityRowSchema,
273
+ { orderBy: 'latestEventAt', orderDir: 'DESC', limit: Math.max(limit * 5, 25) },
274
+ )
275
+
276
+ const items: RecentActivity[] = []
277
+ const seenAreas = new Set<string>()
278
+
279
+ for (const row of rows) {
280
+ const areaKey = buildRecentActivityAreaKey(row)
281
+ if (seenAreas.has(areaKey)) continue
282
+
283
+ seenAreas.add(areaKey)
284
+ items.push(this.toPublicItem(row))
285
+
286
+ if (items.length === limit) break
287
+ }
288
+
289
+ return items
290
+ }
291
+
292
+ async listRecentEvents(params: {
293
+ orgId: RecordIdRef
294
+ userId: RecordIdRef
295
+ limit?: number
296
+ }): Promise<RecentActivityEvent[]> {
297
+ await databaseService.connect()
298
+ const rows = await databaseService.findMany(
299
+ TABLES.RECENT_ACTIVITY_EVENT,
300
+ { organizationId: params.orgId, userId: params.userId },
301
+ RecentActivityEventRowSchema,
302
+ { orderBy: 'occurredAt', orderDir: 'DESC', limit: params.limit ?? 25 },
303
+ )
304
+ return rows.map((row) => this.toPublicEvent(row))
305
+ }
306
+
307
+ async getRecentActivityById(activityId: string | RecordIdRef): Promise<RecentActivity | null> {
308
+ await databaseService.connect()
309
+ const activityRef = ensureRecordId(activityId, TABLES.RECENT_ACTIVITY)
310
+ const row = await databaseService.findOne(TABLES.RECENT_ACTIVITY, { id: activityRef }, RecentActivityRowSchema)
311
+ return row ? this.toPublicItem(row) : null
312
+ }
313
+
314
+ async refineTitle(params: { activityId: string | RecordIdRef; title: string }): Promise<RecentActivity | null> {
315
+ await databaseService.connect()
316
+ const activityRef = ensureRecordId(params.activityId, TABLES.RECENT_ACTIVITY)
317
+ const existing = await databaseService.findOne(TABLES.RECENT_ACTIVITY, { id: activityRef }, RecentActivityRowSchema)
318
+ if (!existing) return null
319
+
320
+ const nextTitle = clampText(params.title, 80)
321
+ if (!nextTitle) return this.toPublicItem(existing)
322
+ if (compactWhitespace(nextTitle).toLowerCase() === compactWhitespace(existing.title).toLowerCase()) {
323
+ return this.toPublicItem(existing)
324
+ }
325
+
326
+ const updated = await databaseService.update(
327
+ TABLES.RECENT_ACTIVITY,
328
+ activityRef,
329
+ { title: nextTitle, titleSource: 'chief', titleRefinedAt: new Date() },
330
+ RecentActivityRowSchema,
331
+ { mutation: 'merge' },
332
+ )
333
+
334
+ return updated ? this.toPublicItem(updated) : null
335
+ }
336
+
337
+ async getRefinementCandidate(
338
+ activityId: string | RecordIdRef,
339
+ ): Promise<{
340
+ id: string
341
+ title: string
342
+ systemTitle: string
343
+ sourceLabel: string
344
+ kind: RecentActivity['kind']
345
+ metadata: z.infer<typeof RecentActivityMetadataSchema>
346
+ } | null> {
347
+ await databaseService.connect()
348
+ const activityRef = ensureRecordId(activityId, TABLES.RECENT_ACTIVITY)
349
+ const row = await databaseService.findOne(TABLES.RECENT_ACTIVITY, { id: activityRef }, RecentActivityRowSchema)
350
+ if (!row) return null
351
+
352
+ return {
353
+ id: recordIdToString(activityRef, TABLES.RECENT_ACTIVITY),
354
+ title: row.title,
355
+ systemTitle: row.systemTitle,
356
+ sourceLabel: row.sourceLabel,
357
+ kind: row.kind,
358
+ metadata: RecentActivityMetadataSchema.parse(row.metadata ?? {}),
359
+ }
360
+ }
361
+
362
+ isMeaningfulRefinementCandidate(activity: RecentActivity | null): boolean {
363
+ if (!activity) return false
364
+ if (activity.kind !== 'chat.turn.completed') return false
365
+ return activity.titleSource === 'system'
366
+ }
367
+
368
+ isChiefTitleUseful(params: { currentTitle: string; systemTitle: string; candidateTitle: string }): boolean {
369
+ const candidate = compactWhitespace(params.candidateTitle)
370
+ if (!candidate) return false
371
+
372
+ const normalizedCandidate = candidate.toLowerCase()
373
+ const normalizedCurrent = compactWhitespace(params.currentTitle).toLowerCase()
374
+ const normalizedSystem = compactWhitespace(params.systemTitle).toLowerCase()
375
+
376
+ if (normalizedCandidate === normalizedCurrent || normalizedCandidate === normalizedSystem) {
377
+ return false
378
+ }
379
+
380
+ if (candidate.length < 8 || candidate.length > 80) {
381
+ return false
382
+ }
383
+
384
+ const bannedTitles = new Set([
385
+ 'follow up',
386
+ 'conversation',
387
+ 'chat',
388
+ 'chief task',
389
+ 'recent activity',
390
+ 'workstream update',
391
+ ])
392
+
393
+ return !bannedTitles.has(normalizedCandidate)
394
+ }
395
+ }
396
+
397
+ export const recentActivityService = new RecentActivityService()