@lota-sdk/core 0.1.13 → 0.1.15

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 (95) hide show
  1. package/package.json +5 -5
  2. package/src/ai/embedding-cache.ts +7 -6
  3. package/src/ai/index.ts +1 -0
  4. package/src/bifrost/bifrost.ts +12 -7
  5. package/src/config/agent-defaults.ts +1 -1
  6. package/src/config/logger.ts +7 -9
  7. package/src/{runtime.ts → create-runtime.ts} +6 -6
  8. package/src/db/cursor-pagination.ts +1 -1
  9. package/src/db/memory-store.ts +10 -6
  10. package/src/db/memory.ts +6 -4
  11. package/src/db/schema-fingerprint.ts +1 -0
  12. package/src/db/service.ts +45 -51
  13. package/src/db/startup.ts +3 -3
  14. package/src/index.ts +1 -1
  15. package/src/queues/context-compaction.queue.ts +4 -8
  16. package/src/queues/document-processor.queue.ts +7 -7
  17. package/src/queues/memory-consolidation.queue.ts +7 -8
  18. package/src/queues/post-chat-memory.queue.ts +2 -6
  19. package/src/queues/recent-activity-title-refinement.queue.ts +2 -6
  20. package/src/queues/regular-chat-memory-digest.queue.ts +4 -7
  21. package/src/queues/skill-extraction.queue.ts +4 -7
  22. package/src/queues/workstream-title-generation.queue.ts +2 -6
  23. package/src/redis/connection.ts +6 -3
  24. package/src/redis/index.ts +1 -0
  25. package/src/redis/org-memory-lock.ts +1 -1
  26. package/src/redis/redis-lease-lock.ts +41 -8
  27. package/src/runtime/agent-stream-helpers.ts +2 -1
  28. package/src/runtime/context-compaction-constants.ts +1 -1
  29. package/src/runtime/context-compaction-runtime.ts +6 -4
  30. package/src/runtime/context-compaction.ts +19 -38
  31. package/src/runtime/execution-plan.ts +2 -2
  32. package/src/runtime/helper-model.ts +3 -1
  33. package/src/runtime/index.ts +12 -1
  34. package/src/runtime/memory-block.ts +3 -2
  35. package/src/runtime/memory-pipeline.ts +24 -5
  36. package/src/runtime/plugin-types.ts +1 -1
  37. package/src/runtime/runtime-extensions.ts +89 -13
  38. package/src/runtime/title-helpers.ts +11 -2
  39. package/src/runtime/workstream-chat-helpers.ts +5 -6
  40. package/src/runtime/workstream-routing-policy.ts +0 -30
  41. package/src/runtime/workstream-state.ts +17 -7
  42. package/src/services/attachment.service.ts +1 -1
  43. package/src/services/context-compaction.service.ts +3 -3
  44. package/src/services/document-chunk.service.ts +37 -32
  45. package/src/services/execution-plan.service.ts +2 -0
  46. package/src/services/learned-skill.service.ts +6 -10
  47. package/src/services/{memory.utils.ts → memory-utils.ts} +4 -8
  48. package/src/services/memory.service.ts +21 -18
  49. package/src/services/organization-member.service.ts +1 -1
  50. package/src/services/plan-artifact.service.ts +1 -0
  51. package/src/services/plan-executor.service.ts +2 -18
  52. package/src/services/plan-helpers.ts +15 -0
  53. package/src/services/plan-validator.service.ts +3 -18
  54. package/src/services/recent-activity-title.service.ts +3 -10
  55. package/src/services/recent-activity.service.ts +6 -12
  56. package/src/services/workstream-message.service.ts +26 -16
  57. package/src/services/workstream-title.service.ts +1 -9
  58. package/src/services/{workstream-turn-preparation.ts → workstream-turn-preparation.service.ts} +401 -314
  59. package/src/services/workstream-turn.ts +2 -2
  60. package/src/services/workstream.service.ts +22 -10
  61. package/src/services/workstream.types.ts +7 -16
  62. package/src/storage/attachment-storage.service.ts +4 -4
  63. package/src/storage/{attachments.utils.ts → attachment-utils.ts} +1 -4
  64. package/src/storage/index.ts +2 -2
  65. package/src/system-agents/{context-compacter.agent.ts → context-compaction.agent.ts} +4 -4
  66. package/src/system-agents/delegated-agent-factory.ts +3 -2
  67. package/src/system-agents/index.ts +8 -0
  68. package/src/system-agents/memory-reranker.agent.ts +1 -1
  69. package/src/system-agents/memory.agent.ts +1 -1
  70. package/src/system-agents/recent-activity-title-refiner.agent.ts +1 -1
  71. package/src/tools/execution-plan.tool.ts +6 -2
  72. package/src/tools/fetch-webpage.tool.ts +20 -18
  73. package/src/tools/index.ts +2 -2
  74. package/src/tools/read-file-parts.tool.ts +1 -1
  75. package/src/tools/search-web.tool.ts +18 -15
  76. package/src/tools/{search-tools.ts → search.tool.ts} +1 -1
  77. package/src/tools/team-think.tool.ts +9 -5
  78. package/src/tools/{tool-contract.ts → tool-contracts.ts} +9 -2
  79. package/src/utils/async.ts +1 -1
  80. package/src/utils/errors.ts +15 -0
  81. package/src/utils/hono-error-handler.ts +1 -2
  82. package/src/utils/index.ts +10 -2
  83. package/src/utils/string.ts +14 -0
  84. package/src/workers/bootstrap.ts +2 -2
  85. package/src/workers/memory-consolidation.worker.ts +12 -12
  86. package/src/workers/regular-chat-memory-digest.helpers.ts +2 -7
  87. package/src/workers/regular-chat-memory-digest.runner.ts +9 -103
  88. package/src/workers/skill-extraction.runner.ts +7 -101
  89. package/src/workers/utils/file-section-chunker.ts +5 -3
  90. package/src/workers/utils/workstream-message-query.ts +106 -0
  91. package/src/workers/worker-utils.ts +4 -0
  92. package/src/runtime/retrieval-pipeline.ts +0 -3
  93. package/src/utils/error.ts +0 -10
  94. /package/src/services/{context-compaction-runtime.ts → context-compaction-runtime.singleton.ts} +0 -0
  95. /package/src/storage/{attachments.types.ts → attachment-types.ts} +0 -0
@@ -3,7 +3,7 @@ import { setTimeout as delay } from 'node:timers/promises'
3
3
 
4
4
  import type IORedis from 'ioredis'
5
5
 
6
- import { getErrorMessage } from '../utils/error'
6
+ import { getErrorMessage } from '../utils/errors'
7
7
 
8
8
  interface RedisLeaseLockLogger {
9
9
  debug?: (message: string) => void
@@ -95,16 +95,30 @@ async function releaseLeaseLock(options: RedisLeaseLockOptions & { lockValue: st
95
95
  await options.redis.eval(RELEASE_LOCK_SCRIPT, 1, options.lockKey, options.lockValue)
96
96
  }
97
97
 
98
+ export class LeaseLockLostError extends Error {
99
+ constructor(message: string) {
100
+ super(message)
101
+ this.name = 'LeaseLockLostError'
102
+ }
103
+ }
104
+
98
105
  function startLeaseLockRefreshLoop(
99
106
  options: Required<Pick<RedisLeaseLockOptions, 'refreshIntervalMs'>> &
100
107
  RedisLeaseLockOptions & { lockValue: string; label: string },
108
+ ac: AbortController,
101
109
  ): () => void {
102
110
  let stopped = false
103
111
 
104
112
  const timer = setInterval(() => {
105
113
  if (stopped) return
106
114
  void refreshLeaseLock(options).catch((error: unknown) => {
107
- log(options.logger, 'warn', `Failed to refresh ${options.label} (${options.lockKey}): ${getErrorMessage(error)}`)
115
+ stopped = true
116
+ clearInterval(timer)
117
+ const message = `Failed to refresh ${options.label} (${options.lockKey}): ${getErrorMessage(error)}`
118
+ log(options.logger, 'warn', message)
119
+ if (!ac.signal.aborted) {
120
+ ac.abort(new LeaseLockLostError(message))
121
+ }
108
122
  })
109
123
  }, options.refreshIntervalMs)
110
124
 
@@ -116,7 +130,10 @@ function startLeaseLockRefreshLoop(
116
130
  }
117
131
  }
118
132
 
119
- export async function withRedisLeaseLock<T>(options: RedisLeaseLockOptions, fn: () => Promise<T>): Promise<T> {
133
+ export async function withRedisLeaseLock<T>(
134
+ options: RedisLeaseLockOptions,
135
+ fn: (signal: AbortSignal) => Promise<T>,
136
+ ): Promise<T> {
120
137
  const lockKey = options.lockKey.trim()
121
138
  if (!lockKey) {
122
139
  throw new Error('Redis lease lock requires a non-empty lock key')
@@ -138,17 +155,33 @@ export async function withRedisLeaseLock<T>(options: RedisLeaseLockOptions, fn:
138
155
  log(options.logger, 'info', `Acquired ${label} (${lockKey}) after waiting waitedMs=${waitedMs}`)
139
156
  }
140
157
 
141
- const stopRefreshLoop = startLeaseLockRefreshLoop({ ...options, lockKey, lockValue, label, refreshIntervalMs })
158
+ const ac = new AbortController()
159
+ const stopRefreshLoop = startLeaseLockRefreshLoop({ ...options, lockKey, lockValue, label, refreshIntervalMs }, ac)
142
160
  const holdStart = Date.now()
143
161
 
144
162
  try {
145
- return await fn()
163
+ const abortPromise = new Promise<never>((_, reject) => {
164
+ if (ac.signal.aborted) {
165
+ reject(ac.signal.reason as Error)
166
+ return
167
+ }
168
+ ac.signal.addEventListener(
169
+ 'abort',
170
+ () => {
171
+ reject(ac.signal.reason as Error)
172
+ },
173
+ { once: true },
174
+ )
175
+ })
176
+ return await Promise.race([fn(ac.signal), abortPromise])
146
177
  } finally {
147
178
  stopRefreshLoop()
148
179
  const heldMs = Date.now() - holdStart
149
- await releaseLeaseLock({ ...options, lockKey, lockValue }).catch((error: unknown) => {
150
- log(options.logger, 'warn', `Failed to release ${label} (${lockKey}): ${getErrorMessage(error)}`)
151
- })
180
+ if (!ac.signal.aborted) {
181
+ await releaseLeaseLock({ ...options, lockKey, lockValue }).catch((error: unknown) => {
182
+ log(options.logger, 'warn', `Failed to release ${label} (${lockKey}): ${getErrorMessage(error)}`)
183
+ })
184
+ }
152
185
  if (heldMs >= heldInfoThresholdMs) {
153
186
  log(options.logger, 'info', `Released ${label} (${lockKey}) heldMs=${heldMs}`)
154
187
  } else {
@@ -2,13 +2,14 @@ import type { ChatMessage } from '@lota-sdk/shared'
2
2
  import type { LanguageModelUsage, UIMessageStreamOptions } from 'ai'
3
3
 
4
4
  import { agentDisplayNames, getLeadAgentDisplayName } from '../config/agent-defaults'
5
+ import { readRecord as _readRecord } from '../utils/string'
5
6
 
6
7
  export function readFiniteNumber(value: unknown): number | undefined {
7
8
  return typeof value === 'number' && Number.isFinite(value) ? value : undefined
8
9
  }
9
10
 
10
11
  export function readRecord(value: unknown): Record<string, unknown> | undefined {
11
- return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined
12
+ return _readRecord(value) ?? undefined
12
13
  }
13
14
 
14
15
  export function readOpenRouterUsageCost(usage: LanguageModelUsage): { cost?: number; upstreamInferenceCost?: number } {
@@ -7,5 +7,5 @@ export const CONTEXT_COMPACTION_INCLUDED_TOOL_NAMES = ['userQuestions', 'proceed
7
7
  export const CONTEXT_COMPACTION_INCLUDED_TOOL_PREFIXES = ['linear'] as const
8
8
  export const MEMORY_BLOCK_COMPACTION_TRIGGER_ENTRIES = 15
9
9
  export const MEMORY_BLOCK_COMPACTION_CHUNK_ENTRIES = 10
10
- export const CONTEXT_SIZE = 200_000
10
+ export const CONTEXT_WINDOW_TOKENS = 200_000
11
11
  export const WORKSTREAM_RAW_TAIL_MESSAGES = 6
@@ -1,4 +1,4 @@
1
- import { createContextCompacterAgent } from '../system-agents/context-compacter.agent'
1
+ import { createContextCompactionAgent } from '../system-agents/context-compaction.agent'
2
2
  import {
3
3
  buildContextCompactionPrompt,
4
4
  buildMemoryBlockCompactionPrompt,
@@ -18,6 +18,8 @@ import {
18
18
  import type { GenerateHelperStructuredParams, GenerateHelperTextParams } from './helper-model'
19
19
  import { StructuredCompactionOutputSchema } from './workstream-state'
20
20
 
21
+ const CONTEXT_COMPACTION_MAX_OUTPUT_TOKENS = 512
22
+
21
23
  interface HelperModelRuntime {
22
24
  generateHelperStructured<T>(params: GenerateHelperStructuredParams<T>): Promise<T>
23
25
  generateHelperText(params: GenerateHelperTextParams): Promise<string>
@@ -32,7 +34,7 @@ interface CreateContextCompactionRuntimeDeps {
32
34
  async function runContextCompacter(helperModelRuntime: HelperModelRuntime, params: ContextCompactionRunnerParams) {
33
35
  const output = await helperModelRuntime.generateHelperStructured({
34
36
  tag: 'context-compaction',
35
- createAgent: createContextCompacterAgent,
37
+ createAgent: createContextCompactionAgent,
36
38
  messages: [
37
39
  {
38
40
  role: 'user',
@@ -76,9 +78,9 @@ export function createWiredContextCompactionRuntime(deps: CreateContextCompactio
76
78
 
77
79
  return await helperModelRuntime.generateHelperText({
78
80
  tag: 'memory-block-compaction',
79
- createAgent: createContextCompacterAgent,
81
+ createAgent: createContextCompactionAgent,
80
82
  messages: [{ role: 'user', content: buildMemoryBlockCompactionPrompt({ previousSummary, newEntriesText }) }],
81
- maxOutputTokens: 512,
83
+ maxOutputTokens: CONTEXT_COMPACTION_MAX_OUTPUT_TOKENS,
82
84
  })
83
85
  }
84
86
 
@@ -2,7 +2,7 @@ import { createHash, randomUUID } from 'node:crypto'
2
2
 
3
3
  import type { ChatMessage } from '@lota-sdk/shared'
4
4
 
5
- import { readString } from '../utils/string'
5
+ import { CHARS_PER_TOKEN_ESTIMATE, compactWhitespace, readRecord, readString } from '../utils/string'
6
6
  import {
7
7
  COMPACTION_CHUNK_MAX_CHARS,
8
8
  CONTEXT_COMPACTION_INCLUDED_TOOL_NAMES,
@@ -15,14 +15,8 @@ import {
15
15
  import {
16
16
  StructuredCompactionOutputSchema,
17
17
  WorkstreamStateDeltaSchema,
18
- WORKSTREAM_STATE_MAX_ACTIVE_CONSTRAINTS,
19
- WORKSTREAM_STATE_MAX_AGENT_CONTRIBUTIONS,
20
- WORKSTREAM_STATE_MAX_ARTIFACTS,
21
- WORKSTREAM_STATE_MAX_KEY_DECISIONS,
22
- WORKSTREAM_STATE_MAX_OPEN_QUESTIONS,
23
- WORKSTREAM_STATE_MAX_RISKS,
24
- WORKSTREAM_STATE_MAX_TASKS,
25
18
  WorkstreamStateSchema,
19
+ applyWorkstreamStateCaps,
26
20
  createEmptyWorkstreamState,
27
21
  parseStructuredWorkstreamStateDelta,
28
22
  } from './workstream-state'
@@ -99,15 +93,11 @@ const PROMPT_INJECTION_PATTERN =
99
93
  /\b(ignore (all )?(previous|prior|system|developer)? instructions?|system prompt|developer prompt|tool override|jailbreak|role ?override|do not follow|bypass)\b/i
100
94
 
101
95
  function estimateTokens(text: string): number {
102
- return Math.ceil(text.length / 3)
103
- }
104
-
105
- function normalizeWhitespace(value: string): string {
106
- return value.replace(/\s+/g, ' ').trim()
96
+ return Math.ceil(text.length / CHARS_PER_TOKEN_ESTIMATE)
107
97
  }
108
98
 
109
99
  function sanitizeStateText(value: string): string | null {
110
- const normalized = normalizeWhitespace(value)
100
+ const normalized = compactWhitespace(value)
111
101
  if (!normalized) return null
112
102
  if (PROMPT_INJECTION_PATTERN.test(normalized)) return null
113
103
  return normalized
@@ -116,16 +106,12 @@ function sanitizeStateText(value: string): string | null {
116
106
  function createStableId(prefix: string, ...parts: Array<string | number | undefined>): string {
117
107
  const payload = parts
118
108
  .map((part) => (part === undefined ? '' : String(part)))
119
- .map((part) => normalizeWhitespace(part))
109
+ .map((part) => compactWhitespace(part))
120
110
  .join('|')
121
111
  const hash = createHash('sha1').update(`${prefix}|${payload}`).digest('hex').slice(0, 20)
122
112
  return `${prefix}_${hash}`
123
113
  }
124
114
 
125
- function readRecord(value: unknown): Record<string, unknown> | null {
126
- return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : null
127
- }
128
-
129
115
  function stringifyUnknown(value: unknown): string | null {
130
116
  if (value === undefined) return null
131
117
  if (typeof value === 'string') {
@@ -141,11 +127,11 @@ function stringifyUnknown(value: unknown): string | null {
141
127
  }
142
128
 
143
129
  function appendUnique(values: string[], nextValues: string[]): string[] {
144
- const seen = new Set(values.map((value) => normalizeWhitespace(value).toLowerCase()))
130
+ const seen = new Set(values.map((value) => compactWhitespace(value).toLowerCase()))
145
131
  const merged = [...values]
146
132
 
147
133
  for (const value of nextValues) {
148
- const normalized = normalizeWhitespace(value)
134
+ const normalized = compactWhitespace(value)
149
135
  if (!normalized) continue
150
136
  const key = normalized.toLowerCase()
151
137
  if (seen.has(key)) continue
@@ -395,7 +381,7 @@ export function mergeStateDelta(
395
381
  const normalizedRationale = sanitizeStateText(decision.rationale)
396
382
  if (!normalizedDecision || !normalizedRationale) continue
397
383
 
398
- const sourceIds = [...new Set(decision.sourceMessageIds.map((id) => normalizeWhitespace(id)).filter(Boolean))]
384
+ const sourceIds = [...new Set(decision.sourceMessageIds.map((id) => compactWhitespace(id)).filter(Boolean))]
399
385
  const decisionId = createStableId('decision', normalizedDecision, normalizedRationale, sourceIds.sort().join('|'))
400
386
  const alreadyExists = state.keyDecisions.some((item) => item.id === decisionId)
401
387
  if (alreadyExists) continue
@@ -403,7 +389,7 @@ export function mergeStateDelta(
403
389
  id: decisionId,
404
390
  decision: normalizedDecision,
405
391
  rationale: normalizedRationale,
406
- agent: normalizeWhitespace(decision.agent),
392
+ agent: compactWhitespace(decision.agent),
407
393
  sourceMessageIds: sourceIds,
408
394
  confidence: decision.confidence,
409
395
  timestamp,
@@ -417,11 +403,9 @@ export function mergeStateDelta(
417
403
  if (!title) continue
418
404
 
419
405
  const externalId = sanitizeStateText(update.externalId ?? '')
420
- const owner = normalizeWhitespace(update.owner)
406
+ const owner = compactWhitespace(update.owner)
421
407
  const taskId = externalId ? createStableId('task-external', externalId) : createStableId('task', title, owner)
422
- const sourceMessageIds = [
423
- ...new Set(update.sourceMessageIds.map((id) => normalizeWhitespace(id)).filter(Boolean)),
424
- ]
408
+ const sourceMessageIds = [...new Set(update.sourceMessageIds.map((id) => compactWhitespace(id)).filter(Boolean))]
425
409
  const existingIndex = state.tasks.findIndex((task) => task.id === taskId)
426
410
  const nextTask = {
427
411
  id: taskId,
@@ -455,7 +439,7 @@ export function mergeStateDelta(
455
439
  const artifactId = createStableId('artifact', name, pointer)
456
440
  const exists = state.artifacts.some((item) => item.id === artifactId)
457
441
  if (exists) continue
458
- state.artifacts.push({ id: artifactId, name, type: normalizeWhitespace(artifact.type), pointer, timestamp })
442
+ state.artifacts.push({ id: artifactId, name, type: compactWhitespace(artifact.type), pointer, timestamp })
459
443
  }
460
444
  }
461
445
 
@@ -495,13 +479,7 @@ export function mergeStateDelta(
495
479
  state.approvalNote = sanitizeStateText(delta.approvalNote) ?? undefined
496
480
  }
497
481
 
498
- state.keyDecisions = state.keyDecisions.slice(-WORKSTREAM_STATE_MAX_KEY_DECISIONS)
499
- state.activeConstraints = state.activeConstraints.slice(-WORKSTREAM_STATE_MAX_ACTIVE_CONSTRAINTS)
500
- state.tasks = state.tasks.slice(-WORKSTREAM_STATE_MAX_TASKS)
501
- state.openQuestions = state.openQuestions.slice(-WORKSTREAM_STATE_MAX_OPEN_QUESTIONS)
502
- state.risks = state.risks.slice(-WORKSTREAM_STATE_MAX_RISKS)
503
- state.artifacts = state.artifacts.slice(-WORKSTREAM_STATE_MAX_ARTIFACTS)
504
- state.agentContributions = state.agentContributions.slice(-WORKSTREAM_STATE_MAX_AGENT_CONTRIBUTIONS)
482
+ applyWorkstreamStateCaps(state)
505
483
 
506
484
  return WorkstreamStateSchema.parse(state)
507
485
  }
@@ -727,9 +705,12 @@ export function createContextCompactionRuntime(
727
705
  return summaryMessage ? [summaryMessage, ...liveMessages] : [...liveMessages]
728
706
  }
729
707
 
708
+ const CONTEXT_OUTPUT_RESERVE_MAX_RATIO = 0.35
709
+ const CONTEXT_SAFETY_MARGIN_MAX_RATIO = 0.1
710
+
730
711
  const estimateThreshold = (contextSize = 256_000): number => {
731
- const reservedOutput = Math.min(outputReserveTokens, Math.floor(contextSize * 0.35))
732
- const safetyMargin = Math.min(safetyMarginTokens, Math.floor(contextSize * 0.1))
712
+ const reservedOutput = Math.min(outputReserveTokens, Math.floor(contextSize * CONTEXT_OUTPUT_RESERVE_MAX_RATIO))
713
+ const safetyMargin = Math.min(safetyMarginTokens, Math.floor(contextSize * CONTEXT_SAFETY_MARGIN_MAX_RATIO))
733
714
  const reservedThreshold = contextSize - (reservedOutput + safetyMargin)
734
715
  const ratioThreshold = Math.floor(contextSize * thresholdRatio)
735
716
  return Math.max(1_000, Math.min(contextSize - 1, Math.min(reservedThreshold, ratioThreshold)))
@@ -834,7 +815,7 @@ export function createContextCompactionRuntime(
834
815
  const contextMessages = messagesToCompact.map(toContextMessageFromChatMessage)
835
816
  const sourceText = toCompactionTranscript(contextMessages)
836
817
 
837
- if (!normalizeWhitespace(sourceText)) {
818
+ if (!compactWhitespace(sourceText)) {
838
819
  const summaryPayload = buildSyntheticSummaryPayload(summaryText)
839
820
  const outputPayload = JSON.stringify([...(summaryPayload ? [summaryPayload] : []), ...remainingMessages])
840
821
  return {
@@ -1,6 +1,6 @@
1
1
  import type { SerializableExecutionPlan } from '@lota-sdk/shared'
2
2
 
3
- export const EXECUTION_PLAN_AGENT_PROTOCOL_PROMPT = `<execution-plan-protocol>
3
+ const EXECUTION_PLAN_AGENT_PROTOCOL_PROMPT = `<execution-plan-protocol>
4
4
  - Before doing multi-step work, create a contract-driven execution plan instead of tracking steps only in prose.
5
5
  - Plans are graph-capable workflow contracts. Every execution node must define objective, instructions, deliverables, success criteria, completion checks, retry policy, failure policy, and tool/context policy.
6
6
  - The runtime executor owns lifecycle truth. Do not claim that a node is complete until submitExecutionNodeResult succeeds.
@@ -10,7 +10,7 @@ export const EXECUTION_PLAN_AGENT_PROTOCOL_PROMPT = `<execution-plan-protocol>
10
10
  - If the graph, contracts, or success criteria materially change, replace the plan instead of silently drifting.
11
11
  </execution-plan-protocol>`
12
12
 
13
- export function formatExecutionPlanForPrompt(plan: SerializableExecutionPlan | null | undefined): string | undefined {
13
+ function formatExecutionPlanForPrompt(plan: SerializableExecutionPlan | null | undefined): string | undefined {
14
14
  if (!plan) return undefined
15
15
 
16
16
  const payload = {
@@ -7,6 +7,8 @@ import type {
7
7
  } from 'ai'
8
8
  import type { ZodSchema } from 'zod'
9
9
 
10
+ import { compactWhitespace } from '../utils/string'
11
+
10
12
  export interface HelperToolLoopAgentOptions {
11
13
  instructions?: string
12
14
  maxOutputTokens?: number
@@ -102,7 +104,7 @@ function stringifyUnknown(value: unknown, maxChars: number): string | null {
102
104
  }
103
105
  })()
104
106
 
105
- const normalized = raw.replace(/\s+/g, ' ').trim()
107
+ const normalized = compactWhitespace(raw)
106
108
  if (!normalized) return null
107
109
  return normalized.length > maxChars ? `${normalized.slice(0, maxChars)}...` : normalized
108
110
  }
@@ -23,4 +23,15 @@ export * from './team-consultation-prompts'
23
23
  export * from './turn-lifecycle'
24
24
  export * from './workstream-chat-helpers'
25
25
  export * from './workstream-routing-policy'
26
- export * from './workstream-state'
26
+ export {
27
+ WorkstreamStateSchema,
28
+ type WorkstreamState,
29
+ type WorkstreamStateDelta,
30
+ StructuredWorkstreamStateDeltaSchema,
31
+ type StructuredWorkstreamStateDelta,
32
+ createEmptyStructuredWorkstreamStateDelta,
33
+ parseStructuredWorkstreamStateDelta,
34
+ StructuredCompactionOutputSchema,
35
+ type CompactionOutput,
36
+ createEmptyWorkstreamState,
37
+ } from './workstream-state'
@@ -1,4 +1,5 @@
1
1
  import { agentDisplayNames, agentShortDisplayNames, resolveAgentNameAlias } from '../config/agent-defaults'
2
+ import { compactWhitespace } from '../utils/string'
2
3
 
3
4
  function escapeRegex(value: string): string {
4
5
  return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
@@ -173,8 +174,7 @@ export function createMemoryBlockRuntime(options: CreateMemoryBlockRuntimeOption
173
174
  )
174
175
  .filter(Boolean)
175
176
 
176
- const candidate = normalizedLines.join(' ').trim()
177
- const collapsed = candidate.replace(/\s+/g, ' ').trim()
177
+ const collapsed = compactWhitespace(normalizedLines.join(' '))
178
178
  if (!collapsed) return ''
179
179
  return collapsed
180
180
  }
@@ -186,6 +186,7 @@ export function createMemoryBlockRuntime(options: CreateMemoryBlockRuntimeOption
186
186
  try {
187
187
  const parsed: unknown = JSON.parse(trimmed)
188
188
  if (!Array.isArray(parsed)) return []
189
+ if (!parsed.every((item: unknown) => typeof item === 'object' && item !== null)) return []
189
190
  return parsed as MemoryBlockEntry[]
190
191
  } catch {
191
192
  return []
@@ -1,3 +1,11 @@
1
+ import { compactWhitespace } from '../utils/string'
2
+
3
+ const SCORE_WEIGHTS = {
4
+ durability: { core: 0.35, standard: 0.2, weak: 0.05 },
5
+ type: { decision: 0.25, preference: 0.18, default: 0.1 },
6
+ maxContentLength: 120,
7
+ } as const
8
+
1
9
  interface MemoryFactInput {
2
10
  content: string
3
11
  confidence: number
@@ -117,9 +125,20 @@ function scoreFact<T extends MemoryFactInput>(fact: T): number {
117
125
  const durability = fact.durability ?? 'standard'
118
126
  const type = fact.type ?? 'fact'
119
127
 
120
- const durabilityWeight = durability === 'core' ? 0.35 : durability === 'standard' ? 0.2 : 0.05
121
- const typeWeight = type === 'decision' ? 0.25 : type === 'fact' ? 0.18 : 0.1
122
- const lengthWeight = Math.min(fact.content.length, 120) / 120 / 10
128
+ const durabilityWeight =
129
+ durability === 'core'
130
+ ? SCORE_WEIGHTS.durability.core
131
+ : durability === 'standard'
132
+ ? SCORE_WEIGHTS.durability.standard
133
+ : SCORE_WEIGHTS.durability.weak
134
+ const typeWeight =
135
+ type === 'decision'
136
+ ? SCORE_WEIGHTS.type.decision
137
+ : type === 'fact'
138
+ ? SCORE_WEIGHTS.type.preference
139
+ : SCORE_WEIGHTS.type.default
140
+ const lengthWeight =
141
+ Math.min(fact.content.length, SCORE_WEIGHTS.maxContentLength) / SCORE_WEIGHTS.maxContentLength / 10
123
142
 
124
143
  return confidence + durabilityWeight + typeWeight + lengthWeight
125
144
  }
@@ -160,7 +179,7 @@ export function postProcessMemoryFacts<T extends MemoryFactInput>(
160
179
  const deduped = new Map<string, T>()
161
180
 
162
181
  for (const fact of rawFacts) {
163
- const content = typeof fact.content === 'string' ? fact.content.replace(/\s+/g, ' ').trim() : ''
182
+ const content = typeof fact.content === 'string' ? compactWhitespace(fact.content) : ''
164
183
  if (!content || content.length < minChars || content.length > maxChars) continue
165
184
  const normalizedFact = { ...fact, content }
166
185
  const key = normalizeFactForDedupe(content)
@@ -464,7 +483,7 @@ export function createMemoryActionPlan<TRelation extends string = string>(params
464
483
  }
465
484
 
466
485
  for (const [index, item] of params.updates.memory.entries()) {
467
- const text = typeof item.text === 'string' ? item.text.replace(/\s+/g, ' ').trim() : ''
486
+ const text = typeof item.text === 'string' ? compactWhitespace(item.text) : ''
468
487
  const itemId = typeof item.id === 'string' ? item.id.trim() : ''
469
488
 
470
489
  switch (item.event) {
@@ -3,7 +3,7 @@ export interface LotaPluginContributions {
3
3
  schemaFiles: readonly (string | URL)[]
4
4
  }
5
5
 
6
- export interface LotaPlugin<TServices = unknown, TTools = unknown> {
6
+ export interface LotaPlugin<TServices = Record<string, unknown>, TTools = Record<string, unknown>> {
7
7
  services: TServices
8
8
  tools?: TTools
9
9
  contributions: LotaPluginContributions
@@ -1,7 +1,7 @@
1
1
  import type { ToolSet } from 'ai'
2
2
 
3
3
  import type { RecordIdRef } from '../db/record-id'
4
- import type { ReadableUploadMetadata } from '../services/attachment.service'
4
+ import type { ReadableUploadMetadata } from '../storage/attachment-types'
5
5
  import type { LotaRuntimeWorkerExtensions } from './runtime-worker-registry'
6
6
 
7
7
  export interface LotaRuntimeBackgroundCursor {
@@ -34,7 +34,7 @@ export interface LotaRuntimeWorkspaceProvider {
34
34
  ): Promise<LotaRuntimeWorkspaceLifecycleState> | LotaRuntimeWorkspaceLifecycleState
35
35
  readProfileProjectionState?(
36
36
  workspace: Record<string, unknown>,
37
- ): Promise<LotaRuntimeWorkspaceProjectionState | void> | LotaRuntimeWorkspaceProjectionState | void
37
+ ): Promise<LotaRuntimeWorkspaceProjectionState | undefined> | LotaRuntimeWorkspaceProjectionState | undefined
38
38
  buildPromptSummary?(workspaceId: RecordIdRef): Promise<string | undefined>
39
39
  listRecentDomainEvents?(workspaceId: RecordIdRef, limit?: number): Promise<Array<Record<string, unknown>>>
40
40
  hasActiveKnowledgeSources?(workspaceId: string): Promise<boolean>
@@ -76,11 +76,87 @@ export interface LotaRuntimeTeamThinkToolsParams {
76
76
  toolProviders?: ToolSet
77
77
  }
78
78
 
79
+ export interface BuildContextParams {
80
+ workstream: unknown
81
+ workstreamRef: RecordIdRef
82
+ orgRef: RecordIdRef
83
+ userRef: RecordIdRef
84
+ userName?: string | null
85
+ workspace: Record<string, unknown>
86
+ onboardingActive: boolean
87
+ messageText: string
88
+ linearInstalled: boolean
89
+ githubInstalled: boolean
90
+ indexedRepoContext: unknown
91
+ promptContext: unknown
92
+ workspaceLifecycleState: unknown
93
+ workspaceProfileState: unknown
94
+ promptSummary: string | undefined
95
+ recentDomainEvents: Array<Record<string, unknown>>
96
+ retrievedKnowledgeSection: string | undefined
97
+ [key: string]: unknown
98
+ }
99
+
100
+ export interface AfterTurnParams {
101
+ workstream: unknown
102
+ workstreamRef: RecordIdRef
103
+ orgRef: RecordIdRef
104
+ userRef: RecordIdRef
105
+ userName?: string | null
106
+ onboardingActive: boolean
107
+ referenceUserMessage: unknown
108
+ assistantMessages: unknown[]
109
+ latestWorkstreamRecord: unknown
110
+ latestPersistedState: unknown
111
+ context: Record<string, unknown> | null
112
+ [key: string]: unknown
113
+ }
114
+
115
+ export interface ResolveAgentParams {
116
+ agentId: string
117
+ mode: string
118
+ workstream: unknown
119
+ workstreamRef: RecordIdRef
120
+ orgRef: RecordIdRef
121
+ userRef: RecordIdRef
122
+ userName?: string | null
123
+ onboardingActive: boolean
124
+ linearInstalled: boolean
125
+ githubInstalled: boolean
126
+ reasoningProfile: string
127
+ skills?: string[]
128
+ additionalInstructionSections?: string[]
129
+ context: Record<string, unknown> | null
130
+ [key: string]: unknown
131
+ }
132
+
133
+ export interface BuildExtraInstructionSectionsParams {
134
+ workstream: unknown
135
+ workstreamRef: RecordIdRef
136
+ orgRef: RecordIdRef
137
+ userRef: RecordIdRef
138
+ userName?: string | null
139
+ workspace: Record<string, unknown>
140
+ onboardingActive: boolean
141
+ messageText: string
142
+ linearInstalled: boolean
143
+ githubInstalled: boolean
144
+ indexedRepoContext: unknown
145
+ promptContext: unknown
146
+ workspaceLifecycleState: unknown
147
+ workspaceProfileState: unknown
148
+ promptSummary: string | undefined
149
+ recentDomainEvents: Array<Record<string, unknown>>
150
+ retrievedKnowledgeSection: string | undefined
151
+ context: Record<string, unknown> | null
152
+ [key: string]: unknown
153
+ }
154
+
79
155
  export interface LotaRuntimeTurnHooks {
80
- buildContext?: (params: Record<string, unknown>) => Promise<Record<string, unknown> | void>
81
- afterTurn?: (params: Record<string, unknown>) => Promise<void>
82
- resolveAgent?: (params: Record<string, unknown>) => Promise<Record<string, unknown> | void>
83
- buildExtraInstructionSections?: (params: Record<string, unknown>) => Promise<string[] | void>
156
+ buildContext?: (params: BuildContextParams) => Promise<Record<string, unknown> | void>
157
+ afterTurn?: (params: AfterTurnParams) => Promise<void>
158
+ resolveAgent?: (params: ResolveAgentParams) => Promise<Record<string, unknown> | void>
159
+ buildExtraInstructionSections?: (params: BuildExtraInstructionSectionsParams) => Promise<string[] | void>
84
160
  }
85
161
 
86
162
  export interface LotaRuntimeAdapters {
@@ -90,8 +166,8 @@ export interface LotaRuntimeAdapters {
90
166
  buildTeamThinkAgentTools?: (params: LotaRuntimeTeamThinkToolsParams) => Promise<{ tools: ToolSet }>
91
167
  }
92
168
  queues?: {
93
- enqueuePostChatOrgAction?: (job: Record<string, unknown>) => Promise<void> | void
94
- enqueueOnboardingRepoIndexFollowUp?: (job: Record<string, unknown>) => Promise<void> | void
169
+ enqueuePostChatOrgAction?: (job: Record<string, unknown>) => Promise<void>
170
+ enqueueOnboardingRepoIndexFollowUp?: (job: Record<string, unknown>) => Promise<void>
95
171
  }
96
172
  workers?: {
97
173
  connectPluginDatabases?: () => Promise<void>
@@ -115,17 +191,17 @@ let runtimeExtensionsState: RuntimeExtensionsState = {
115
191
  extraWorkers: {},
116
192
  }
117
193
 
118
- export function configureRuntimeExtensions(params?: {
194
+ export function configureRuntimeExtensions(params: {
119
195
  adapters?: LotaRuntimeAdapters
120
196
  turnHooks?: LotaRuntimeTurnHooks
121
197
  toolProviders?: ToolSet
122
198
  extraWorkers?: LotaRuntimeWorkerExtensions
123
199
  }): void {
124
200
  runtimeExtensionsState = {
125
- adapters: params?.adapters ?? {},
126
- turnHooks: params?.turnHooks ?? {},
127
- toolProviders: params?.toolProviders ?? EMPTY_TOOLS,
128
- extraWorkers: params?.extraWorkers ?? {},
201
+ adapters: params.adapters ?? {},
202
+ turnHooks: params.turnHooks ?? {},
203
+ toolProviders: params.toolProviders ?? EMPTY_TOOLS,
204
+ extraWorkers: params.extraWorkers ?? {},
129
205
  }
130
206
  }
131
207
 
@@ -1,12 +1,21 @@
1
+ import { compactWhitespace } from '../utils/string'
2
+
1
3
  const TITLE_WORD_LIMIT = 5
2
4
 
3
5
  export function limitTitleWords(text: string): string {
4
- const words = text.replace(/\s+/g, ' ').trim().split(' ').filter(Boolean)
6
+ const words = compactWhitespace(text).split(' ').filter(Boolean)
5
7
  return words.slice(0, TITLE_WORD_LIMIT).join(' ')
6
8
  }
7
9
 
8
10
  export function deriveTitle(text: string): string {
9
- const trimmed = text.replace(/\s+/g, ' ').trim()
11
+ const trimmed = compactWhitespace(text)
10
12
  if (trimmed.length <= 60) return trimmed
11
13
  return `${trimmed.slice(0, 57)}...`
12
14
  }
15
+
16
+ export function normalizeTitle(value: string): string {
17
+ const normalized = compactWhitespace(value)
18
+ .replace(/^["'`]+|["'`]+$/g, '')
19
+ .replace(/[.!?,;:]+$/g, '')
20
+ return normalized.length <= 80 ? normalized : normalized.slice(0, 80).trim()
21
+ }
@@ -114,14 +114,13 @@ export function collectToolOutputErrors(params: {
114
114
  if (typeof part !== 'object') continue
115
115
  if (part.type !== undefined && typeof part.type !== 'string') continue
116
116
  if (!part.type?.startsWith('tool-')) continue
117
- if ((part as Record<string, unknown>).state !== 'output-error') continue
117
+
118
+ const p = part as Record<string, unknown>
119
+ if (p.state !== 'output-error') continue
118
120
 
119
121
  const toolName = part.type.slice('tool-'.length) || 'unknown'
120
- const toolCallId =
121
- typeof (part as Record<string, unknown>).toolCallId === 'string' && (part as Record<string, unknown>).toolCallId
122
- ? ((part as Record<string, unknown>).toolCallId as string)
123
- : 'unknown'
124
- const errorTextRaw = (part as Record<string, unknown>).errorText
122
+ const toolCallId = typeof p.toolCallId === 'string' && p.toolCallId ? p.toolCallId : 'unknown'
123
+ const errorTextRaw = p.errorText
125
124
  const errorText =
126
125
  typeof errorTextRaw === 'string' && errorTextRaw.trim()
127
126
  ? errorTextRaw.trim()