@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,71 @@
|
|
|
1
|
+
import type { ErrorHandler } from 'hono'
|
|
2
|
+
import { HTTPException } from 'hono/http-exception'
|
|
3
|
+
import { ZodError } from 'zod'
|
|
4
|
+
|
|
5
|
+
import { getErrorMessage } from './error'
|
|
6
|
+
import { AppError } from './errors'
|
|
7
|
+
|
|
8
|
+
type AppErrorLike = Pick<AppError, 'code' | 'message' | 'statusCode' | 'toResponse'> & { name?: string }
|
|
9
|
+
|
|
10
|
+
type HonoErrorLogger = Pick<Console, 'warn' | 'error'>
|
|
11
|
+
|
|
12
|
+
function isAppErrorLike(error: unknown): error is AppErrorLike {
|
|
13
|
+
if (!error || typeof error !== 'object') {
|
|
14
|
+
return false
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const candidate = error as Partial<AppErrorLike>
|
|
18
|
+
return (
|
|
19
|
+
typeof candidate.code === 'string' &&
|
|
20
|
+
typeof candidate.message === 'string' &&
|
|
21
|
+
typeof candidate.statusCode === 'number' &&
|
|
22
|
+
typeof candidate.toResponse === 'function'
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function createValidationErrorResponse(issues: Array<{ path: string; message: string }>) {
|
|
27
|
+
return { error: { code: 'VALIDATION_ERROR', message: 'Validation failed', issues } }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function createHttpErrorResponse(message: string) {
|
|
31
|
+
return { error: { code: 'HTTP_ERROR', message } }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function createServerErrorResponse(message: string) {
|
|
35
|
+
return { error: { code: 'INTERNAL_SERVER_ERROR', message } }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function createHonoErrorHandler(logger: HonoErrorLogger): ErrorHandler {
|
|
39
|
+
return (error, c) => {
|
|
40
|
+
const appError = error instanceof AppError || isAppErrorLike(error) ? error : null
|
|
41
|
+
|
|
42
|
+
if (appError) {
|
|
43
|
+
const log = appError.statusCode >= 500 ? logger.error : logger.warn
|
|
44
|
+
const errorName = typeof appError.name === 'string' && appError.name.length > 0 ? appError.name : 'AppError'
|
|
45
|
+
log(`Request failed: ${errorName} (${appError.code}) ${appError.message}`)
|
|
46
|
+
const { status, body } = appError.toResponse()
|
|
47
|
+
const typedStatus = status as Parameters<typeof c.json>[1]
|
|
48
|
+
return c.json(body, typedStatus)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (error instanceof HTTPException) {
|
|
52
|
+
const log = error.status >= 500 ? logger.error : logger.warn
|
|
53
|
+
log(`Request failed: HTTPException ${error.status} ${error.message}`)
|
|
54
|
+
return error.res ?? c.json(createHttpErrorResponse(error.message), error.status)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (error instanceof ZodError) {
|
|
58
|
+
logger.warn(`Request failed: ZodError ${error.message}`)
|
|
59
|
+
const issues = error.issues.map((issue) => ({ path: issue.path.join('.'), message: issue.message }))
|
|
60
|
+
return c.json(createValidationErrorResponse(issues), 400)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (error instanceof Error) {
|
|
64
|
+
logger.error(`Server error: ${error.name} ${error.message}\n${error.stack ?? ''}`)
|
|
65
|
+
return c.json(createServerErrorResponse('Internal Server Error'), 500)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
logger.error(`Server error: ${getErrorMessage(error)}`)
|
|
69
|
+
return c.json(createServerErrorResponse('Internal Server Error'), 500)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns true if the value is a non-null, non-array object (i.e. a plain record).
|
|
3
|
+
*/
|
|
4
|
+
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
5
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Reads a trimmed, non-empty string from an unknown value.
|
|
10
|
+
* Returns null for non-strings and empty/whitespace-only strings.
|
|
11
|
+
*/
|
|
12
|
+
export function readString(value: unknown): string | null {
|
|
13
|
+
if (typeof value !== 'string') return null
|
|
14
|
+
const trimmed = value.trim()
|
|
15
|
+
return trimmed.length > 0 ? trimmed : null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Reads a trimmed, non-empty string from a record field by key.
|
|
20
|
+
* Returns undefined when the field is missing or not a non-empty string.
|
|
21
|
+
*/
|
|
22
|
+
export function readStringField(record: Record<string, unknown>, key: string): string | undefined {
|
|
23
|
+
const value = record[key]
|
|
24
|
+
return typeof value === 'string' ? value : undefined
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Truncates a string to the given character limit, appending "..." when truncated.
|
|
29
|
+
* Returns the original string when it fits within maxChars.
|
|
30
|
+
*/
|
|
31
|
+
export function truncateText(value: string, maxChars: number): string {
|
|
32
|
+
return value.length <= maxChars ? value : `${value.slice(0, maxChars).trimEnd()}...`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Truncates an optional string value with trimming. Returns undefined for
|
|
37
|
+
* falsy or whitespace-only input.
|
|
38
|
+
*/
|
|
39
|
+
export function truncateOptionalText(value: string | undefined, maxChars: number): string | undefined {
|
|
40
|
+
if (!value) return undefined
|
|
41
|
+
const trimmed = value.trim()
|
|
42
|
+
if (!trimmed) return undefined
|
|
43
|
+
return trimmed.length <= maxChars ? trimmed : `${trimmed.slice(0, maxChars)}...`
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Collapses all runs of whitespace into a single space and trims.
|
|
48
|
+
*/
|
|
49
|
+
export function compactWhitespace(value: string): string {
|
|
50
|
+
return value.trim().replace(/\s+/g, ' ')
|
|
51
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { configureLogger, serverLogger } from '../config/logger'
|
|
2
|
+
import { databaseService } from '../db/service'
|
|
3
|
+
import { connectWithStartupRetry, waitForDatabaseBootstrap } from '../db/startup'
|
|
4
|
+
import { getConfiguredPluginDatabaseConnector } from '../runtime/runtime-extensions'
|
|
5
|
+
|
|
6
|
+
let sandboxedWorkerRuntimePromise: Promise<void> | null = null
|
|
7
|
+
|
|
8
|
+
export async function initializeSandboxedWorkerRuntime(): Promise<void> {
|
|
9
|
+
if (sandboxedWorkerRuntimePromise) {
|
|
10
|
+
await sandboxedWorkerRuntimePromise
|
|
11
|
+
return
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
sandboxedWorkerRuntimePromise = (async () => {
|
|
15
|
+
await configureLogger()
|
|
16
|
+
|
|
17
|
+
await connectWithStartupRetry({
|
|
18
|
+
connect: () => databaseService.connect(),
|
|
19
|
+
label: 'sandboxed worker AI database runtime',
|
|
20
|
+
logger: serverLogger,
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
await connectWithStartupRetry({
|
|
24
|
+
connect: async () => {
|
|
25
|
+
const connectPluginRuntimeDatabases = getConfiguredPluginDatabaseConnector()
|
|
26
|
+
if (connectPluginRuntimeDatabases) {
|
|
27
|
+
await connectPluginRuntimeDatabases()
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
label: 'sandboxed worker plugin database runtime',
|
|
31
|
+
logger: serverLogger,
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
await waitForDatabaseBootstrap({
|
|
35
|
+
databaseService,
|
|
36
|
+
expectedFingerprint: process.env.DB_SCHEMA_FINGERPRINT,
|
|
37
|
+
label: 'sandboxed worker runtime',
|
|
38
|
+
logger: serverLogger,
|
|
39
|
+
connect: () => databaseService.connect(),
|
|
40
|
+
})
|
|
41
|
+
})()
|
|
42
|
+
|
|
43
|
+
await sandboxedWorkerRuntimePromise
|
|
44
|
+
}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import type { SandboxedJob } from 'bullmq'
|
|
2
|
+
import { BoundQuery, eq, inside } from 'surrealdb'
|
|
3
|
+
|
|
4
|
+
import { serverLogger } from '../config/logger'
|
|
5
|
+
import { ensureRecordId, recordIdToString } from '../db/record-id'
|
|
6
|
+
import type { RecordIdInput } from '../db/record-id'
|
|
7
|
+
import { databaseService } from '../db/service'
|
|
8
|
+
import { TABLES } from '../db/tables'
|
|
9
|
+
import type { MemoryConsolidationJob } from '../queues/memory-consolidation.queue'
|
|
10
|
+
import { initializeSandboxedWorkerRuntime } from './bootstrap'
|
|
11
|
+
import { toSandboxedWorkerError } from './utils/sandbox-error'
|
|
12
|
+
import { createTracedWorkerProcessor } from './worker-utils'
|
|
13
|
+
|
|
14
|
+
await initializeSandboxedWorkerRuntime()
|
|
15
|
+
|
|
16
|
+
const MEMORY_TABLE = TABLES.MEMORY
|
|
17
|
+
const MEMORY_RELATION_TABLE = TABLES.MEMORY_RELATION
|
|
18
|
+
const MEMORY_HISTORY_TABLE = TABLES.MEMORY_HISTORY
|
|
19
|
+
const HARD_SIMILARITY_THRESHOLD = 0.95
|
|
20
|
+
const SOFT_SIMILARITY_THRESHOLD = 0.9
|
|
21
|
+
const MAX_MEMORIES_PER_SCOPE = 500
|
|
22
|
+
|
|
23
|
+
function toMemoryId(value: RecordIdInput): string {
|
|
24
|
+
return recordIdToString(value, TABLES.MEMORY)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isContentSubsumed(shorter: string, longer: string): boolean {
|
|
28
|
+
const a = shorter.toLowerCase().trim()
|
|
29
|
+
const b = longer.toLowerCase().trim()
|
|
30
|
+
if (b.includes(a)) return true
|
|
31
|
+
const aWords = new Set(a.split(/\s+/))
|
|
32
|
+
const bWords = new Set(b.split(/\s+/))
|
|
33
|
+
let overlap = 0
|
|
34
|
+
for (const word of aWords) {
|
|
35
|
+
if (bWords.has(word)) overlap++
|
|
36
|
+
}
|
|
37
|
+
return aWords.size > 0 && overlap / aWords.size >= 0.8
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function deduplicateScope(scopeId: string): Promise<number> {
|
|
41
|
+
const memoryRows = await databaseService.query<{
|
|
42
|
+
id: RecordIdInput
|
|
43
|
+
content: string
|
|
44
|
+
importance: number
|
|
45
|
+
embedding: number[]
|
|
46
|
+
updatedAt?: string | Date | number | null
|
|
47
|
+
createdAt: string | Date | number
|
|
48
|
+
}>(
|
|
49
|
+
new BoundQuery(
|
|
50
|
+
`SELECT id, content, importance, embedding, updatedAt, createdAt
|
|
51
|
+
FROM ${MEMORY_TABLE}
|
|
52
|
+
WHERE scopeId = $scopeId AND archivedAt IS NONE
|
|
53
|
+
ORDER BY createdAt DESC
|
|
54
|
+
LIMIT $limit`,
|
|
55
|
+
{ scopeId, limit: MAX_MEMORIES_PER_SCOPE },
|
|
56
|
+
),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
const memories = memoryRows.map((row) => ({ ...row, id: toMemoryId(row.id) }))
|
|
60
|
+
if (memories.length < 2) return 0
|
|
61
|
+
|
|
62
|
+
const archived = new Set<string>()
|
|
63
|
+
let mergeCount = 0
|
|
64
|
+
|
|
65
|
+
for (const memory of memories) {
|
|
66
|
+
if (archived.has(memory.id)) continue
|
|
67
|
+
|
|
68
|
+
const candidateLimit = 20
|
|
69
|
+
const neighborStatements = await databaseService.queryAll<{
|
|
70
|
+
id: RecordIdInput
|
|
71
|
+
content: string
|
|
72
|
+
importance: number
|
|
73
|
+
similarity: number
|
|
74
|
+
updatedAt?: string | Date | number | null
|
|
75
|
+
createdAt: string | Date | number
|
|
76
|
+
}>(
|
|
77
|
+
new BoundQuery(
|
|
78
|
+
`LET $candidateRows = (
|
|
79
|
+
SELECT
|
|
80
|
+
id,
|
|
81
|
+
vector::distance::knn() AS distance
|
|
82
|
+
FROM ${MEMORY_TABLE}
|
|
83
|
+
WHERE scopeId = $scopeId
|
|
84
|
+
AND archivedAt IS NONE
|
|
85
|
+
AND id != $memoryId
|
|
86
|
+
AND embedding <|${candidateLimit}|> $embedding
|
|
87
|
+
ORDER BY distance ASC
|
|
88
|
+
LIMIT ${candidateLimit}
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
SELECT
|
|
92
|
+
id,
|
|
93
|
+
content,
|
|
94
|
+
importance,
|
|
95
|
+
updatedAt,
|
|
96
|
+
createdAt,
|
|
97
|
+
vector::similarity::cosine(embedding, $embedding) AS similarity
|
|
98
|
+
FROM ${MEMORY_TABLE}
|
|
99
|
+
WHERE id IN $candidateRows.id
|
|
100
|
+
ORDER BY similarity DESC
|
|
101
|
+
LIMIT ${candidateLimit}`,
|
|
102
|
+
{ scopeId, memoryId: ensureRecordId(memory.id, TABLES.MEMORY), embedding: memory.embedding },
|
|
103
|
+
),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
const neighbors = (neighborStatements.at(-1) ?? []).map((row) => ({ ...row, id: toMemoryId(row.id) }))
|
|
107
|
+
|
|
108
|
+
for (const neighbor of neighbors) {
|
|
109
|
+
if (neighbor.similarity < SOFT_SIMILARITY_THRESHOLD) break
|
|
110
|
+
if (archived.has(neighbor.id)) continue
|
|
111
|
+
|
|
112
|
+
const isHardMatch = neighbor.similarity >= HARD_SIMILARITY_THRESHOLD
|
|
113
|
+
if (!isHardMatch) {
|
|
114
|
+
const [shorter, longer] =
|
|
115
|
+
memory.content.length <= neighbor.content.length
|
|
116
|
+
? [memory.content, neighbor.content]
|
|
117
|
+
: [neighbor.content, memory.content]
|
|
118
|
+
if (!isContentSubsumed(shorter, longer)) continue
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const memoryCreatedAt = new Date(memory.createdAt).getTime()
|
|
122
|
+
const neighborCreatedAt = new Date(neighbor.createdAt).getTime()
|
|
123
|
+
const keepMemory =
|
|
124
|
+
memory.importance > neighbor.importance ||
|
|
125
|
+
(memory.importance === neighbor.importance && memoryCreatedAt >= neighborCreatedAt)
|
|
126
|
+
const winner = keepMemory ? memory : neighbor
|
|
127
|
+
const loser = keepMemory ? neighbor : memory
|
|
128
|
+
|
|
129
|
+
await databaseService.relate(
|
|
130
|
+
ensureRecordId(winner.id, TABLES.MEMORY),
|
|
131
|
+
MEMORY_RELATION_TABLE,
|
|
132
|
+
ensureRecordId(loser.id, TABLES.MEMORY),
|
|
133
|
+
{ relationType: 'supersedes', confidence: 1.0 },
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
await databaseService.query(
|
|
137
|
+
new BoundQuery(
|
|
138
|
+
`UPDATE ${MEMORY_TABLE}
|
|
139
|
+
SET archivedAt = time::now(), validUntil = time::now()
|
|
140
|
+
WHERE id = $loserId AND archivedAt IS NONE`,
|
|
141
|
+
{ loserId: ensureRecordId(loser.id, TABLES.MEMORY) },
|
|
142
|
+
),
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
await databaseService.insert<Record<string, unknown>>(MEMORY_HISTORY_TABLE, {
|
|
146
|
+
memoryId: ensureRecordId(loser.id, TABLES.MEMORY),
|
|
147
|
+
prevValue: loser.content,
|
|
148
|
+
event: 'DELETE',
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
archived.add(loser.id)
|
|
152
|
+
mergeCount++
|
|
153
|
+
|
|
154
|
+
if (!keepMemory) break
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return mergeCount
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function pruneStaleMemories(): Promise<number> {
|
|
162
|
+
const stale = await databaseService.query<{ id: RecordIdInput }>(
|
|
163
|
+
new BoundQuery(
|
|
164
|
+
`SELECT id FROM ${MEMORY_TABLE}
|
|
165
|
+
WHERE accessCount = 0
|
|
166
|
+
AND createdAt < time::now() - 90d
|
|
167
|
+
AND archivedAt IS NONE
|
|
168
|
+
AND importance < 0.5
|
|
169
|
+
LIMIT 200`,
|
|
170
|
+
),
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
if (stale.length === 0) return 0
|
|
174
|
+
|
|
175
|
+
const staleIds = stale.map((row) => ensureRecordId(row.id, TABLES.MEMORY))
|
|
176
|
+
await databaseService.updateWhere(MEMORY_TABLE, inside('id', staleIds), { archivedAt: new Date() })
|
|
177
|
+
|
|
178
|
+
return stale.length
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function collapseSupersedeCh(): Promise<number> {
|
|
182
|
+
const middleNodes = await databaseService.query<{
|
|
183
|
+
middleId: RecordIdInput
|
|
184
|
+
predecessors: RecordIdInput[]
|
|
185
|
+
successors: RecordIdInput[]
|
|
186
|
+
}>(
|
|
187
|
+
new BoundQuery(
|
|
188
|
+
`SELECT
|
|
189
|
+
id AS middleId,
|
|
190
|
+
<-${MEMORY_RELATION_TABLE}[WHERE relationType = 'supersedes']<-${MEMORY_TABLE}.id AS predecessors,
|
|
191
|
+
->${MEMORY_RELATION_TABLE}[WHERE relationType = 'supersedes']->${MEMORY_TABLE}.id AS successors
|
|
192
|
+
FROM ${MEMORY_TABLE}
|
|
193
|
+
WHERE archivedAt IS NONE
|
|
194
|
+
AND count(->${MEMORY_RELATION_TABLE}[WHERE relationType = 'supersedes']) > 0
|
|
195
|
+
AND count(<-${MEMORY_RELATION_TABLE}[WHERE relationType = 'supersedes']) > 0
|
|
196
|
+
LIMIT 100`,
|
|
197
|
+
),
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
let collapsed = 0
|
|
201
|
+
|
|
202
|
+
for (const node of middleNodes) {
|
|
203
|
+
const predecessors = node.predecessors
|
|
204
|
+
const successors = node.successors
|
|
205
|
+
|
|
206
|
+
for (const predId of predecessors) {
|
|
207
|
+
for (const succId of successors) {
|
|
208
|
+
const predRef = ensureRecordId(predId, TABLES.MEMORY)
|
|
209
|
+
const succRef = ensureRecordId(succId, TABLES.MEMORY)
|
|
210
|
+
const existing = await databaseService.query<{ id: RecordIdInput }>(
|
|
211
|
+
new BoundQuery(
|
|
212
|
+
`SELECT id FROM ${MEMORY_RELATION_TABLE}
|
|
213
|
+
WHERE in = $predId AND out = $succId AND relationType = 'supersedes'
|
|
214
|
+
LIMIT 1`,
|
|
215
|
+
{ predId: predRef, succId: succRef },
|
|
216
|
+
),
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
if (existing.length === 0) {
|
|
220
|
+
await databaseService.relate(predRef, MEMORY_RELATION_TABLE, succRef, {
|
|
221
|
+
relationType: 'supersedes',
|
|
222
|
+
confidence: 1.0,
|
|
223
|
+
})
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
await databaseService.updateWhere(MEMORY_TABLE, eq('id', ensureRecordId(node.middleId, TABLES.MEMORY)), {
|
|
229
|
+
archivedAt: new Date(),
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
collapsed++
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return collapsed
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function decayImportance(): Promise<number> {
|
|
239
|
+
const standardResult = await databaseService.query<{ count: number }>(
|
|
240
|
+
new BoundQuery(
|
|
241
|
+
`UPDATE ${MEMORY_TABLE}
|
|
242
|
+
SET importance = math::max([0.1, importance * 0.95])
|
|
243
|
+
WHERE lastAccessedAt IS NOT NONE
|
|
244
|
+
AND lastAccessedAt < time::now() - 30d
|
|
245
|
+
AND archivedAt IS NONE
|
|
246
|
+
AND importance > 0.1
|
|
247
|
+
AND (durability = 'standard' OR durability IS NONE)
|
|
248
|
+
RETURN count() AS count`,
|
|
249
|
+
),
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
const ephemeralResult = await databaseService.query<{ count: number }>(
|
|
253
|
+
new BoundQuery(
|
|
254
|
+
`UPDATE ${MEMORY_TABLE}
|
|
255
|
+
SET importance = math::max([0.1, importance * 0.85])
|
|
256
|
+
WHERE lastAccessedAt IS NOT NONE
|
|
257
|
+
AND lastAccessedAt < time::now() - 30d
|
|
258
|
+
AND archivedAt IS NONE
|
|
259
|
+
AND importance > 0.1
|
|
260
|
+
AND durability = 'ephemeral'
|
|
261
|
+
RETURN count() AS count`,
|
|
262
|
+
),
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
return (standardResult[0]?.count ?? 0) + (ephemeralResult[0]?.count ?? 0)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function cleanupOrphanedRelations(): Promise<number> {
|
|
269
|
+
const result = await databaseService.query<{ count: number }>(
|
|
270
|
+
new BoundQuery(
|
|
271
|
+
`DELETE ${MEMORY_RELATION_TABLE}
|
|
272
|
+
WHERE in.archivedAt IS NOT NONE OR out.archivedAt IS NOT NONE
|
|
273
|
+
RETURN count() AS count`,
|
|
274
|
+
),
|
|
275
|
+
)
|
|
276
|
+
return result[0]?.count ?? 0
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function pruneOldOrgActions(): Promise<number> {
|
|
280
|
+
const result = await databaseService.query<{ count: number }>(
|
|
281
|
+
new BoundQuery(`DELETE ${TABLES.ORG_ACTION} WHERE createdAt < time::now() - 90d RETURN count() AS count`),
|
|
282
|
+
)
|
|
283
|
+
return result[0]?.count ?? 0
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const handler = async (job: SandboxedJob<MemoryConsolidationJob>) => {
|
|
287
|
+
const targetScope = job.data.scopeId
|
|
288
|
+
|
|
289
|
+
try {
|
|
290
|
+
let totalMerged = 0
|
|
291
|
+
if (targetScope) {
|
|
292
|
+
totalMerged = await deduplicateScope(targetScope)
|
|
293
|
+
} else {
|
|
294
|
+
const scopeIds = await databaseService.query<string>(
|
|
295
|
+
new BoundQuery(`SELECT VALUE scopeId FROM ${MEMORY_TABLE} WHERE archivedAt IS NONE GROUP BY scopeId`),
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
for (const scopeId of scopeIds) {
|
|
299
|
+
const merged = await deduplicateScope(scopeId)
|
|
300
|
+
totalMerged += merged
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const pruned = await pruneStaleMemories()
|
|
305
|
+
const collapsed = await collapseSupersedeCh()
|
|
306
|
+
const decayed = await decayImportance()
|
|
307
|
+
const orphaned = await cleanupOrphanedRelations()
|
|
308
|
+
const prunedActions = await pruneOldOrgActions()
|
|
309
|
+
|
|
310
|
+
serverLogger.info`Memory consolidation complete (merged: ${totalMerged}, pruned: ${pruned}, collapsed: ${collapsed}, decayed: ${decayed}, orphaned relations: ${orphaned}, pruned actions: ${prunedActions})`
|
|
311
|
+
} catch (error) {
|
|
312
|
+
const serialized = toSandboxedWorkerError(error, 'Memory consolidation job failed')
|
|
313
|
+
serverLogger.error`${serialized.message}`
|
|
314
|
+
throw serialized
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export default createTracedWorkerProcessor('memory-consolidation', handler)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { isAgentName } from '../config/agent-defaults'
|
|
2
|
+
|
|
3
|
+
interface DigestMessageForTranscript {
|
|
4
|
+
source: 'workstream'
|
|
5
|
+
sourceId: string
|
|
6
|
+
role: 'system' | 'user' | 'assistant'
|
|
7
|
+
parts: Array<Record<string, unknown>>
|
|
8
|
+
metadata?: Record<string, unknown>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function normalizeWhitespace(value: string): string {
|
|
12
|
+
return value.replace(/\s+/g, ' ').trim()
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function normalizeFilePartMetadata(part: Record<string, unknown>): string | null {
|
|
16
|
+
if (part.type !== 'file') return null
|
|
17
|
+
|
|
18
|
+
const filename = typeof part.filename === 'string' && part.filename.trim() ? part.filename.trim() : 'attachment'
|
|
19
|
+
const mediaType = typeof part.mediaType === 'string' && part.mediaType.trim() ? part.mediaType.trim() : 'unknown'
|
|
20
|
+
const storageKey = typeof part.storageKey === 'string' && part.storageKey.trim() ? part.storageKey.trim() : 'unknown'
|
|
21
|
+
const sizeBytes =
|
|
22
|
+
typeof part.sizeBytes === 'number' && Number.isFinite(part.sizeBytes) && part.sizeBytes >= 0
|
|
23
|
+
? Math.trunc(part.sizeBytes)
|
|
24
|
+
: null
|
|
25
|
+
|
|
26
|
+
const sizeSegment = sizeBytes === null ? '' : `, sizeBytes=${sizeBytes}`
|
|
27
|
+
return `${filename} (${mediaType}${sizeSegment}, storageKey=${storageKey})`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function extractAssistantLabel(message: DigestMessageForTranscript): string {
|
|
31
|
+
const metadataAgentId =
|
|
32
|
+
message.metadata && typeof message.metadata.agentId === 'string'
|
|
33
|
+
? message.metadata.agentId.trim().toLowerCase()
|
|
34
|
+
: ''
|
|
35
|
+
if (metadataAgentId && isAgentName(metadataAgentId)) {
|
|
36
|
+
return metadataAgentId
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const metadataAgentName =
|
|
40
|
+
message.metadata && typeof message.metadata.agentName === 'string' ? message.metadata.agentName.trim() : ''
|
|
41
|
+
if (metadataAgentName) {
|
|
42
|
+
return metadataAgentName
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return 'assistant'
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function buildDigestTranscript(params: { messages: DigestMessageForTranscript[] }): {
|
|
49
|
+
transcript: string
|
|
50
|
+
involvedAgentNames: string[]
|
|
51
|
+
} {
|
|
52
|
+
const lines: string[] = []
|
|
53
|
+
const involvedAgentNames = new Set<string>()
|
|
54
|
+
|
|
55
|
+
for (const message of params.messages) {
|
|
56
|
+
if (message.role !== 'user' && message.role !== 'assistant') continue
|
|
57
|
+
|
|
58
|
+
const sourcePrefix = `[${message.source}:${message.sourceId}]`
|
|
59
|
+
const textParts = message.parts
|
|
60
|
+
.flatMap((part) =>
|
|
61
|
+
part.type === 'text' && typeof part.text === 'string' ? [normalizeWhitespace(part.text)] : [],
|
|
62
|
+
)
|
|
63
|
+
.filter((value) => value.length > 0)
|
|
64
|
+
const fileParts = message.parts
|
|
65
|
+
.map((part) => normalizeFilePartMetadata(part))
|
|
66
|
+
.filter((value): value is string => typeof value === 'string' && value.length > 0)
|
|
67
|
+
|
|
68
|
+
if (message.role === 'user') {
|
|
69
|
+
for (const textPart of textParts) {
|
|
70
|
+
lines.push(`${sourcePrefix} User: ${textPart}`)
|
|
71
|
+
}
|
|
72
|
+
if (fileParts.length > 0) {
|
|
73
|
+
lines.push(`${sourcePrefix} User files: ${fileParts.join('; ')}`)
|
|
74
|
+
}
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const assistantLabel = extractAssistantLabel(message)
|
|
79
|
+
if (isAgentName(assistantLabel)) {
|
|
80
|
+
involvedAgentNames.add(assistantLabel)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
for (const textPart of textParts) {
|
|
84
|
+
lines.push(`${sourcePrefix} [${assistantLabel}] ${textPart}`)
|
|
85
|
+
}
|
|
86
|
+
if (fileParts.length > 0) {
|
|
87
|
+
lines.push(`${sourcePrefix} [${assistantLabel}] files: ${fileParts.join('; ')}`)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { transcript: lines.join('\n'), involvedAgentNames: [...involvedAgentNames] }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function resolveWorkspaceBootstrapCutoff(params: {
|
|
95
|
+
hasExistingCursor: boolean
|
|
96
|
+
bootstrapCompletedAt?: Date | null
|
|
97
|
+
}): Date | null {
|
|
98
|
+
if (params.hasExistingCursor) return null
|
|
99
|
+
return params.bootstrapCompletedAt instanceof Date ? params.bootstrapCompletedAt : null
|
|
100
|
+
}
|