@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.
- package/infrastructure/schema/00_workstream.surql +55 -0
- package/infrastructure/schema/01_memory.surql +47 -0
- package/infrastructure/schema/02_execution_plan.surql +62 -0
- package/infrastructure/schema/03_learned_skill.surql +32 -0
- package/infrastructure/schema/04_runtime_bootstrap.surql +8 -0
- package/package.json +128 -0
- package/src/ai/definitions.ts +308 -0
- package/src/bifrost/bifrost.ts +256 -0
- package/src/config/agent-defaults.ts +99 -0
- package/src/config/constants.ts +33 -0
- package/src/config/env-shapes.ts +122 -0
- package/src/config/logger.ts +29 -0
- package/src/config/model-constants.ts +31 -0
- package/src/config/search.ts +17 -0
- package/src/config/workstream-defaults.ts +68 -0
- package/src/db/base.service.ts +55 -0
- package/src/db/cursor-pagination.ts +73 -0
- package/src/db/memory-query-builder.ts +207 -0
- package/src/db/memory-store.helpers.ts +118 -0
- package/src/db/memory-store.rows.ts +29 -0
- package/src/db/memory-store.ts +974 -0
- package/src/db/memory-types.ts +193 -0
- package/src/db/memory.ts +505 -0
- package/src/db/record-id.ts +78 -0
- package/src/db/service.ts +932 -0
- package/src/db/startup.ts +152 -0
- package/src/db/tables.ts +20 -0
- package/src/document/org-document-chunking.ts +224 -0
- package/src/document/parsing.ts +40 -0
- package/src/embeddings/provider.ts +76 -0
- package/src/index.ts +302 -0
- package/src/queues/context-compaction.queue.ts +82 -0
- package/src/queues/document-processor.queue.ts +118 -0
- package/src/queues/memory-consolidation.queue.ts +65 -0
- package/src/queues/post-chat-memory.queue.ts +128 -0
- package/src/queues/recent-activity-title-refinement.queue.ts +69 -0
- package/src/queues/regular-chat-memory-digest.config.ts +12 -0
- package/src/queues/regular-chat-memory-digest.queue.ts +73 -0
- package/src/queues/skill-extraction.config.ts +9 -0
- package/src/queues/skill-extraction.queue.ts +62 -0
- package/src/redis/connection.ts +176 -0
- package/src/redis/index.ts +30 -0
- package/src/redis/org-memory-lock.ts +43 -0
- package/src/redis/redis-lease-lock.ts +158 -0
- package/src/runtime/agent-contract.ts +1 -0
- package/src/runtime/agent-prompt-context.ts +119 -0
- package/src/runtime/agent-runtime-policy.ts +192 -0
- package/src/runtime/agent-stream-helpers.ts +117 -0
- package/src/runtime/agent-types.ts +22 -0
- package/src/runtime/approval-continuation.ts +16 -0
- package/src/runtime/chat-attachments.ts +46 -0
- package/src/runtime/chat-message.ts +10 -0
- package/src/runtime/chat-request-routing.ts +21 -0
- package/src/runtime/chat-run-orchestration.ts +25 -0
- package/src/runtime/chat-run-registry.ts +20 -0
- package/src/runtime/chat-types.ts +18 -0
- package/src/runtime/context-compaction-constants.ts +11 -0
- package/src/runtime/context-compaction-runtime.ts +86 -0
- package/src/runtime/context-compaction.ts +909 -0
- package/src/runtime/execution-plan.ts +59 -0
- package/src/runtime/helper-model.ts +405 -0
- package/src/runtime/indexed-repositories-policy.ts +28 -0
- package/src/runtime/instruction-sections.ts +8 -0
- package/src/runtime/llm-content.ts +71 -0
- package/src/runtime/memory-block.ts +264 -0
- package/src/runtime/memory-digest-policy.ts +14 -0
- package/src/runtime/memory-format.ts +8 -0
- package/src/runtime/memory-pipeline.ts +570 -0
- package/src/runtime/memory-prompts-fact.ts +47 -0
- package/src/runtime/memory-prompts-parse.ts +3 -0
- package/src/runtime/memory-prompts-update.ts +37 -0
- package/src/runtime/memory-scope.ts +43 -0
- package/src/runtime/plugin-types.ts +10 -0
- package/src/runtime/retrieval-adapters.ts +25 -0
- package/src/runtime/retrieval-pipeline.ts +3 -0
- package/src/runtime/runtime-extensions.ts +154 -0
- package/src/runtime/skill-extraction-policy.ts +3 -0
- package/src/runtime/team-consultation-orchestrator.ts +245 -0
- package/src/runtime/team-consultation-prompts.ts +32 -0
- package/src/runtime/title-helpers.ts +12 -0
- package/src/runtime/turn-lifecycle.ts +28 -0
- package/src/runtime/workstream-chat-helpers.ts +187 -0
- package/src/runtime/workstream-routing-policy.ts +301 -0
- package/src/runtime/workstream-state.ts +261 -0
- package/src/services/attachment.service.ts +159 -0
- package/src/services/chat-attachments.service.ts +17 -0
- package/src/services/chat-run-registry.service.ts +3 -0
- package/src/services/context-compaction-runtime.ts +13 -0
- package/src/services/context-compaction.service.ts +115 -0
- package/src/services/document-chunk.service.ts +141 -0
- package/src/services/execution-plan.service.ts +890 -0
- package/src/services/learned-skill.service.ts +328 -0
- package/src/services/memory-assessment.service.ts +43 -0
- package/src/services/memory.service.ts +807 -0
- package/src/services/memory.utils.ts +84 -0
- package/src/services/mutating-approval.service.ts +110 -0
- package/src/services/recent-activity-title.service.ts +74 -0
- package/src/services/recent-activity.service.ts +397 -0
- package/src/services/workstream-change-tracker.service.ts +313 -0
- package/src/services/workstream-message.service.ts +283 -0
- package/src/services/workstream-title.service.ts +58 -0
- package/src/services/workstream-turn-preparation.ts +1340 -0
- package/src/services/workstream-turn.ts +37 -0
- package/src/services/workstream.service.ts +854 -0
- package/src/services/workstream.types.ts +118 -0
- package/src/storage/attachment-parser.ts +101 -0
- package/src/storage/attachment-storage.service.ts +391 -0
- package/src/storage/attachments.types.ts +11 -0
- package/src/storage/attachments.utils.ts +58 -0
- package/src/storage/generated-document-storage.service.ts +55 -0
- package/src/system-agents/agent-result.ts +27 -0
- package/src/system-agents/context-compacter.agent.ts +46 -0
- package/src/system-agents/delegated-agent-factory.ts +177 -0
- package/src/system-agents/helper-agent-options.ts +20 -0
- package/src/system-agents/memory-reranker.agent.ts +38 -0
- package/src/system-agents/memory.agent.ts +58 -0
- package/src/system-agents/recent-activity-title-refiner.agent.ts +53 -0
- package/src/system-agents/regular-chat-memory-digest.agent.ts +75 -0
- package/src/system-agents/researcher.agent.ts +34 -0
- package/src/system-agents/skill-extractor.agent.ts +88 -0
- package/src/system-agents/skill-manager.agent.ts +80 -0
- package/src/system-agents/title-generator.agent.ts +42 -0
- package/src/system-agents/workstream-tracker.agent.ts +58 -0
- package/src/tools/execution-plan.tool.ts +163 -0
- package/src/tools/fetch-webpage.tool.ts +132 -0
- package/src/tools/firecrawl-client.ts +12 -0
- package/src/tools/memory-block.tool.ts +55 -0
- package/src/tools/read-file-parts.tool.ts +80 -0
- package/src/tools/remember-memory.tool.ts +85 -0
- package/src/tools/research-topic.tool.ts +15 -0
- package/src/tools/search-tools.ts +55 -0
- package/src/tools/search-web.tool.ts +175 -0
- package/src/tools/team-think.tool.ts +125 -0
- package/src/tools/tool-contract.ts +21 -0
- package/src/tools/user-questions.tool.ts +18 -0
- package/src/utils/async.ts +50 -0
- package/src/utils/date-time.ts +34 -0
- package/src/utils/error.ts +10 -0
- package/src/utils/errors.ts +28 -0
- package/src/utils/hono-error-handler.ts +71 -0
- package/src/utils/string.ts +51 -0
- package/src/workers/bootstrap.ts +44 -0
- package/src/workers/memory-consolidation.worker.ts +318 -0
- package/src/workers/regular-chat-memory-digest.helpers.ts +100 -0
- package/src/workers/regular-chat-memory-digest.runner.ts +363 -0
- package/src/workers/regular-chat-memory-digest.worker.ts +22 -0
- package/src/workers/skill-extraction.runner.ts +331 -0
- package/src/workers/skill-extraction.worker.ts +22 -0
- package/src/workers/utils/repo-indexer-chunker.ts +331 -0
- package/src/workers/utils/repo-structure-extractor.ts +645 -0
- package/src/workers/utils/repomix-process-concurrency.ts +65 -0
- package/src/workers/utils/sandbox-error.ts +5 -0
- 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()
|