@soederpop/luca 0.2.2 → 0.2.3

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.
@@ -2,11 +2,13 @@ import { z } from 'zod'
2
2
  import { FeatureStateSchema, FeatureOptionsSchema, FeatureEventsSchema } from '../../schemas/base.js'
3
3
  import { type AvailableFeatures } from '@soederpop/luca/feature'
4
4
  import { Feature } from '../feature.js'
5
- import type { Conversation, ConversationTool, ContentPart, AskOptions, Message } from './conversation'
5
+ import type { Conversation, ConversationTool, ContentPart, AskOptions, ForkOptions, Message } from './conversation'
6
6
  import type { ContentDb } from '@soederpop/luca/node'
7
7
  import type { ConversationHistory, ConversationMeta } from './conversation-history'
8
8
  import hashObject from '../../hash-object.js'
9
9
  import { InterceptorChain, type InterceptorFn, type InterceptorPoints, type InterceptorPoint } from '../lib/interceptor-chain.js'
10
+ import type { Entity } from '../../entity.js'
11
+ import { State } from '../../state.js'
10
12
 
11
13
  declare module '@soederpop/luca/feature' {
12
14
  interface AvailableFeatures {
@@ -49,6 +51,7 @@ export const AssistantStateSchema = FeatureStateSchema.extend({
49
51
  pendingPlugins: z.array(z.any()).describe('Pending async plugin promises'),
50
52
  conversation: z.any().nullable().describe('The active Conversation feature instance'),
51
53
  subagents: z.record(z.string(), z.any()).describe('Cached subagent instances'),
54
+ forkDepth: z.number().describe('How many times this assistant has been forked from an ancestor. 0 = original.'),
52
55
  })
53
56
 
54
57
  export const AssistantOptionsSchema = FeatureOptionsSchema.extend({
@@ -107,11 +110,55 @@ export const AssistantOptionsSchema = FeatureOptionsSchema.extend({
107
110
 
108
111
  /** Convenience alias for allowTools — an explicit list of tool names (exact matches only). */
109
112
  toolNames: z.array(z.string()).optional().describe('Explicit list of tool names to include (exact match). Shorthand for allowTools without glob patterns.'),
113
+
114
+ /** Options passed through to the underlying OpenAI client (e.g. baseURL, apiKey). */
115
+ clientOptions: z.record(z.string(), z.any()).optional().describe('Options for the OpenAI client, passed through to the conversation'),
110
116
  })
111
117
 
112
118
  export type AssistantState = z.infer<typeof AssistantStateSchema>
113
119
  export type AssistantOptions = z.infer<typeof AssistantOptionsSchema>
114
120
 
121
+ /** Fork options extended with assistant-specific tool filtering and lifecycle hooks. */
122
+ export type AssistantForkOptions = ForkOptions & {
123
+ /** Denylist of tool name patterns to exclude from the fork. Supports "*" glob matching. */
124
+ forbidTools?: string[]
125
+ /** Strict allowlist of tool name patterns for the fork. Supports "*" glob matching. */
126
+ allowTools?: string[]
127
+ /** Explicit list of tool names to include in the fork (exact match). */
128
+ toolNames?: string[]
129
+ /**
130
+ * Called with the forked assistant after it has been fully initialized (started, interceptors cloned,
131
+ * system prompt extensions copied, forkDepth set). Use this to add/remove tools, tweak state,
132
+ * inject system prompt extensions, or anything else before the fork is used.
133
+ */
134
+ onFork?: (fork: Assistant, parent: Assistant) => void | Promise<void>
135
+ }
136
+
137
+ export interface ResearchJobState {
138
+ status: 'running' | 'completed' | 'failed'
139
+ prompt: string
140
+ questions: string[]
141
+ results: (string | null)[]
142
+ errors: (string | null)[]
143
+ completed: number
144
+ total: number
145
+ }
146
+
147
+ export interface ResearchJobOptions {
148
+ prompt: string
149
+ questions: string[]
150
+ forkOptions: AssistantForkOptions
151
+ }
152
+
153
+ export type ResearchJobEvents = {
154
+ forkCompleted: [number, string]
155
+ forkError: [number, string]
156
+ completed: [string[]]
157
+ failed: [(string | null)[]]
158
+ }
159
+
160
+ export type ResearchJob = Entity<ResearchJobState, ResearchJobOptions, ResearchJobEvents>
161
+
115
162
  /**
116
163
  * An Assistant is a combination of a system prompt and tool calls that has a
117
164
  * conversation with an LLM. You define an assistant by creating a folder with
@@ -143,6 +190,22 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
143
190
  beforeResponse: new InterceptorChain<InterceptorPoints['beforeResponse']>(),
144
191
  }
145
192
 
193
+ /**
194
+ * Extension point for plugins, setupToolsConsumer, and hooks to attach
195
+ * arbitrary methods to the assistant instance (e.g. voice-mode adding
196
+ * mute/unmute). Access via `assistant.ext.myMethod()`.
197
+ */
198
+ readonly ext: Record<string, (...args: any[]) => any> = {}
199
+
200
+ /**
201
+ * Observable runtime state that the assistant can manipulate freely via
202
+ * tool calls, hooks, or extensions. Unlike the feature's own `state`
203
+ * (which tracks internal lifecycle), mentalState is a blank slate for
204
+ * the assistant's own use — tracking mood, goals, context, preferences,
205
+ * or anything else. Fully observable so UI or other systems can react.
206
+ */
207
+ readonly mentalState = new State<Record<string, any>>()
208
+
146
209
  /**
147
210
  * Register an interceptor at a given point in the pipeline.
148
211
  *
@@ -159,6 +222,26 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
159
222
  return this
160
223
  }
161
224
 
225
+ /**
226
+ * Trigger a named hook and await its completion. The hook function receives
227
+ * `(assistant, ...args)` and its return value is passed back to the caller.
228
+ * This ensures hooks run to completion BEFORE any subsequent logic executes,
229
+ * unlike the old bus-based approach where async hooks were fire-and-forget.
230
+ *
231
+ * Hooks that don't exist are silently skipped (returns undefined).
232
+ *
233
+ * @param hookName - The hook to trigger (matches an export name from hooks.ts)
234
+ * @param args - Arguments passed to the hook after the assistant instance
235
+ * @returns The hook's return value, or undefined if no hook exists
236
+ */
237
+ async triggerHook(hookName: string, ...args: any[]): Promise<any> {
238
+ const hooks = (this.state.get('hooks') || {}) as Record<string, (...args: any[]) => any>
239
+ const hookFn = hooks[hookName]
240
+ if (!hookFn) return undefined
241
+ this.emit('hookFired', hookName)
242
+ return await hookFn(this, ...args)
243
+ }
244
+
162
245
  /** @returns Default state with the assistant not started, zero conversations, and the resolved folder path. */
163
246
  override get initialState(): AssistantState {
164
247
  return {
@@ -245,8 +328,8 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
245
328
 
246
329
  /**
247
330
  * Called immediately after the assistant is constructed. Synchronously loads
248
- * the system prompt, tools, and hooks, then binds hooks as event listeners
249
- * so every emitted event automatically invokes its corresponding hook.
331
+ * the system prompt, tools, and hooks. Hooks are invoked via triggerHook()
332
+ * at each emit site, ensuring async hooks are properly awaited.
250
333
  */
251
334
  override afterInitialize() {
252
335
  this.state.set('pendingPlugins', [])
@@ -258,10 +341,11 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
258
341
  this.state.set('tools', this.loadTools())
259
342
  this.state.set('hooks', this.loadHooks())
260
343
 
261
- // Bind hooks to events BEFORE emitting created so the created hook fires
262
- this.bindHooksToEvents()
263
-
264
- setTimeout(() => this.emit('created'), 1)
344
+ // Defer created hook+event so external listeners can register first
345
+ setTimeout(async () => {
346
+ await this.triggerHook('created')
347
+ this.emit('created')
348
+ }, 1)
265
349
  }
266
350
 
267
351
  get conversation(): Conversation {
@@ -279,6 +363,7 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
279
363
  ...(this.effectiveOptions.frequencyPenalty != null ? { frequencyPenalty: this.effectiveOptions.frequencyPenalty } : {}),
280
364
  ...(this.effectiveOptions.presencePenalty != null ? { presencePenalty: this.effectiveOptions.presencePenalty } : {}),
281
365
  ...(this.effectiveOptions.stop ? { stop: this.effectiveOptions.stop } : {}),
366
+ ...(this.effectiveOptions.clientOptions ? { clientOptions: this.effectiveOptions.clientOptions } : {}),
282
367
  history: [
283
368
  { role: 'system', content: this.effectiveSystemPrompt },
284
369
  ],
@@ -301,6 +386,16 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
301
386
  return !!this.state.get('started')
302
387
  }
303
388
 
389
+ /** Whether this assistant was created via fork(). */
390
+ get isFork(): boolean {
391
+ return (this.state.get('forkDepth') ?? 0) > 0
392
+ }
393
+
394
+ /** How many levels deep this fork is. 0 = original, 1 = direct fork, 2 = fork of a fork, etc. */
395
+ get forkDepth(): number {
396
+ return (this.state.get('forkDepth') as number) ?? 0
397
+ }
398
+
304
399
  /** The current system prompt text. */
305
400
  get systemPrompt(): string {
306
401
  return this.state.get('systemPrompt') || ''
@@ -451,6 +546,9 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
451
546
  }
452
547
  } else if (fnOrHelper && 'schemas' in fnOrHelper && 'handlers' in fnOrHelper) {
453
548
  this._registerTools(fnOrHelper as { schemas: Record<string, z.ZodType>, handlers: Record<string, Function> })
549
+ if (typeof (fnOrHelper as any).setup === 'function') {
550
+ (fnOrHelper as any).setup(this)
551
+ }
454
552
  }
455
553
  return this
456
554
  }
@@ -704,11 +802,17 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
704
802
  return this.mergeOptionTools(tools)
705
803
  }
706
804
 
805
+ // Stash `export const use = [...]` for deferred processing during start(),
806
+ // since the assistant isn't fully constructed yet when loadTools() runs
807
+ if (Array.isArray(moduleExports.use)) {
808
+ this.state.set('deferredUse', moduleExports.use)
809
+ }
810
+
707
811
  if (Object.keys(moduleExports).length) {
708
812
  const schemas: Record<string, z.ZodType> = moduleExports.schemas || {}
709
813
 
710
814
  for (const [name, fn] of Object.entries(moduleExports)) {
711
- if (name === 'schemas' || name === 'default' || typeof fn !== 'function') continue
815
+ if (name === 'schemas' || name === 'default' || name === 'use' || typeof fn !== 'function') continue
712
816
 
713
817
  const schema = schemas[name]
714
818
  if (schema) {
@@ -963,41 +1067,9 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
963
1067
  }
964
1068
  }
965
1069
 
966
- /**
967
- * Bind all loaded hook functions as event listeners. Each hook whose
968
- * name matches an event gets wired up so it fires automatically when
969
- * that event is emitted. Must be called before any events are emitted.
970
- */
971
- /** Hook names that are called directly during lifecycle, not bound as event listeners. */
972
- private static lifecycleHooks = new Set(['formatSystemPrompt'])
973
- /** Stored references to bound hook listeners so they can be unbound on reload. Lazily initialized because afterInitialize runs before field initializers. */
974
- private _boundHookListeners!: Array<{ event: string; listener: (...args: any[]) => void }>
975
1070
  /** Tool names added at runtime via addTool()/use(), so reload() can preserve them. */
976
1071
  private _runtimeToolNames!: Set<string>
977
1072
 
978
- private bindHooksToEvents() {
979
- if (!this._boundHookListeners) this._boundHookListeners = []
980
- const assistant = this
981
- const hooks = (this.state.get('hooks') || {}) as Record<string, (...args: any[]) => any>
982
- for (const [eventName, hookFn] of Object.entries(hooks)) {
983
- if (Assistant.lifecycleHooks.has(eventName)) continue
984
- const listener = (...args: any[]) => {
985
- this.emit('hookFired', eventName)
986
- hookFn(assistant, ...args)
987
- }
988
- this._boundHookListeners.push({ event: eventName, listener })
989
- this.on(eventName as any, listener)
990
- }
991
- }
992
-
993
- private unbindHooksFromEvents() {
994
- if (!this._boundHookListeners) return
995
- for (const { event, listener } of this._boundHookListeners) {
996
- this.off(event as any, listener)
997
- }
998
- this._boundHookListeners = []
999
- }
1000
-
1001
1073
  /**
1002
1074
  * Reload tools, hooks, and system prompt from disk. Useful during development
1003
1075
  * or when tool/hook files have been modified and you want the assistant to
@@ -1006,9 +1078,6 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
1006
1078
  * @returns this, for chaining
1007
1079
  */
1008
1080
  reload(): this {
1009
- // Unbind existing hook listeners
1010
- this.unbindHooksFromEvents()
1011
-
1012
1081
  // Snapshot runtime-added tools before reloading from disk
1013
1082
  const runtimeTools: Record<string, ConversationTool> = {}
1014
1083
  if (this._runtimeToolNames?.size) {
@@ -1026,9 +1095,8 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
1026
1095
  this.state.set('tools', { ...diskTools, ...runtimeTools })
1027
1096
  this.emit('toolsChanged')
1028
1097
 
1029
- // Reload hooks from disk and rebind
1098
+ // Reload hooks from disk triggerHook reads from state so new hooks are active immediately
1030
1099
  this.state.set('hooks', this.loadHooks())
1031
- this.bindHooksToEvents()
1032
1100
 
1033
1101
  this.emit('reloaded')
1034
1102
 
@@ -1046,6 +1114,19 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
1046
1114
  // Prevent duplicate listener registration if already started
1047
1115
  if (this.isStarted) return this
1048
1116
 
1117
+ // Process deferred `use` entries from tools.ts (stashed during loadTools
1118
+ // because the assistant isn't fully constructed at that point)
1119
+ const deferredUse = this.state.get('deferredUse') as any[] | undefined
1120
+ if (deferredUse?.length) {
1121
+ for (const entry of deferredUse) {
1122
+ this.use(entry)
1123
+ }
1124
+ this.state.set('deferredUse', undefined)
1125
+ }
1126
+
1127
+ // Allow hooks to run before the assistant starts (blocks until complete)
1128
+ await this.triggerHook('beforeStart')
1129
+
1049
1130
  // Wait for any async .use() plugins to finish before starting
1050
1131
  const pending = this.state.get('pendingPlugins') as Promise<void>[]
1051
1132
  if (pending.length) {
@@ -1055,43 +1136,72 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
1055
1136
 
1056
1137
  // Allow hooks.ts to export a formatSystemPrompt(assistant, prompt) => string
1057
1138
  // that transforms the system prompt before the conversation is created.
1058
- const hooks = (this.state.get('hooks') || {}) as Record<string, (...args: any[]) => any>
1059
- if (hooks.formatSystemPrompt) {
1060
- const result = await hooks.formatSystemPrompt(this, this.systemPrompt)
1061
- if (typeof result === 'string') {
1062
- this.state.set('systemPrompt', result)
1063
- }
1139
+ const formatted = await this.triggerHook('formatSystemPrompt', this.systemPrompt)
1140
+ if (typeof formatted === 'string') {
1141
+ this.state.set('systemPrompt', formatted)
1064
1142
  }
1065
1143
 
1066
1144
  // Wire up event forwarding from conversation to assistant.
1067
- // Hooks fire automatically because they're bound as event listeners.
1068
- this.conversation.on('turnStart', (info: any) => this.emit('turnStart', info))
1069
- this.conversation.on('turnEnd', (info: any) => this.emit('turnEnd', info))
1070
- this.conversation.on('chunk', (chunk: string) => this.emit('chunk', chunk))
1071
- this.conversation.on('preview', (text: string) => this.emit('preview', text))
1072
- this.conversation.on('response', (text: string) => {
1145
+ // Each forwarded event triggers its hook (awaited) before emitting on the assistant bus.
1146
+ this.conversation.on('turnStart', async (info: any) => {
1147
+ await this.triggerHook('turnStart', info)
1148
+ this.emit('turnStart', info)
1149
+ })
1150
+ this.conversation.on('turnEnd', async (info: any) => {
1151
+ await this.triggerHook('turnEnd', info)
1152
+ this.emit('turnEnd', info)
1153
+ })
1154
+ this.conversation.on('chunk', async (chunk: string) => {
1155
+ await this.triggerHook('chunk', chunk)
1156
+ this.emit('chunk', chunk)
1157
+ })
1158
+ this.conversation.on('preview', async (text: string) => {
1159
+ await this.triggerHook('preview', text)
1160
+ this.emit('preview', text)
1161
+ })
1162
+ this.conversation.on('response', async (text: string) => {
1163
+ await this.triggerHook('response', text)
1073
1164
  this.emit('response', text)
1074
1165
  this.state.set('lastResponse', text)
1075
1166
  })
1076
- this.conversation.on('rawEvent', (event: any) => this.emit('rawEvent', event))
1077
- this.conversation.on('mcpEvent', (event: any) => this.emit('mcpEvent', event))
1078
- this.conversation.on('toolCall', (name: string, args: any) => this.emit('toolCall', name, args))
1079
- this.conversation.on('toolResult', (name: string, result: any) => this.emit('toolResult', name, result))
1080
- this.conversation.on('toolError', (name: string, error: any) => this.emit('toolError', name, error))
1167
+ this.conversation.on('rawEvent', async (event: any) => {
1168
+ await this.triggerHook('rawEvent', event)
1169
+ this.emit('rawEvent', event)
1170
+ })
1171
+ this.conversation.on('mcpEvent', async (event: any) => {
1172
+ await this.triggerHook('mcpEvent', event)
1173
+ this.emit('mcpEvent', event)
1174
+ })
1175
+ this.conversation.on('toolCall', async (name: string, args: any) => {
1176
+ await this.triggerHook('toolCall', name, args)
1177
+ this.emit('toolCall', name, args)
1178
+ })
1179
+ this.conversation.on('toolResult', async (name: string, result: any) => {
1180
+ await this.triggerHook('toolResult', name, result)
1181
+ this.emit('toolResult', name, result)
1182
+ })
1183
+ this.conversation.on('toolError', async (name: string, error: any) => {
1184
+ await this.triggerHook('toolError', name, error)
1185
+ this.emit('toolError', name, error)
1186
+ })
1081
1187
 
1082
1188
  // Install interceptor-aware tool executor on the conversation
1083
1189
  this.conversation.toolExecutor = async (name: string, args: Record<string, any>, handler: (...a: any[]) => Promise<any>) => {
1084
1190
  const ctx = { name, args, result: undefined as string | undefined, error: undefined, skip: false }
1085
1191
 
1192
+ // Hook runs first (awaited), then interceptor chain
1193
+ await this.triggerHook('beforeToolCall', ctx)
1086
1194
  await this.interceptors.beforeToolCall.run(ctx, async () => {})
1087
1195
 
1088
1196
  if (ctx.skip) {
1089
1197
  const result = ctx.result ?? JSON.stringify({ skipped: true })
1198
+ await this.triggerHook('toolResult', ctx.name, result)
1090
1199
  this.emit('toolResult', ctx.name, result)
1091
1200
  return result
1092
1201
  }
1093
1202
 
1094
1203
  try {
1204
+ await this.triggerHook('toolCall', ctx.name, ctx.args)
1095
1205
  this.emit('toolCall', ctx.name, ctx.args)
1096
1206
  const output = await handler(ctx.args)
1097
1207
  ctx.result = typeof output === 'string' ? output : JSON.stringify(output)
@@ -1100,11 +1210,15 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
1100
1210
  ctx.result = JSON.stringify({ error: err.message || String(err) })
1101
1211
  }
1102
1212
 
1213
+ // Hook runs first (awaited), then interceptor chain
1214
+ await this.triggerHook('afterToolCall', ctx)
1103
1215
  await this.interceptors.afterToolCall.run(ctx, async () => {})
1104
1216
 
1105
1217
  if (ctx.error && !ctx.result?.includes('"error"')) {
1218
+ await this.triggerHook('toolError', ctx.name, ctx.error)
1106
1219
  this.emit('toolError', ctx.name, ctx.error)
1107
1220
  } else {
1221
+ await this.triggerHook('toolResult', ctx.name, ctx.result!)
1108
1222
  this.emit('toolResult', ctx.name, ctx.result!)
1109
1223
  }
1110
1224
 
@@ -1128,8 +1242,12 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
1128
1242
  })
1129
1243
 
1130
1244
  this.state.set('started', true)
1245
+ await this.triggerHook('started')
1131
1246
  this.emit('started')
1132
1247
 
1248
+ // afterStart blocks until complete — use for setup that needs the full assistant ready
1249
+ await this.triggerHook('afterStart')
1250
+
1133
1251
  return this
1134
1252
  }
1135
1253
 
@@ -1161,6 +1279,17 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
1161
1279
  question = this.prependTimestamp(question)
1162
1280
  }
1163
1281
 
1282
+ // Trigger beforeInitialAsk only on the first ask() call
1283
+ if (count === 1) {
1284
+ await this.triggerHook('beforeInitialAsk', question, options)
1285
+ }
1286
+
1287
+ // Trigger beforeAsk hook on every ask() call — can modify question via return value
1288
+ const hookResult = await this.triggerHook('beforeAsk', question, options)
1289
+ if (typeof hookResult === 'string') {
1290
+ question = hookResult
1291
+ }
1292
+
1164
1293
  // Run beforeAsk interceptors — they can rewrite the question or short-circuit
1165
1294
  if (this.interceptors.beforeAsk.hasInterceptors) {
1166
1295
  const ctx = { question, options } as InterceptorPoints['beforeAsk']
@@ -1184,6 +1313,7 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
1184
1313
  await this.conversation.save({ thread: this.state.get('threadId') })
1185
1314
  }
1186
1315
 
1316
+ await this.triggerHook('answered', result)
1187
1317
  this.emit('answered', result)
1188
1318
 
1189
1319
  return result
@@ -1203,6 +1333,246 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
1203
1333
  return this.conversation.save(opts)
1204
1334
  }
1205
1335
 
1336
+ // -- Fork & Research API --
1337
+
1338
+ /**
1339
+ * Fork the assistant into a new independent instance. The fork gets its own
1340
+ * conversation (with configurable history truncation) but preserves the
1341
+ * assistant's full identity: interceptors, tools, hooks, system prompt extensions.
1342
+ *
1343
+ * @param options - Fork options including history truncation and conversation overrides
1344
+ * - `history: 'full'` (default) — deep copy all messages
1345
+ * - `history: 'none'` — system prompt only
1346
+ * - `history: number` — keep last N exchanges + system prompt
1347
+ * - Plus any conversation creation overrides (model, maxTokens, temperature, etc.)
1348
+ *
1349
+ * When called with an array, creates multiple independent forks.
1350
+ *
1351
+ * @example
1352
+ * ```typescript
1353
+ * // Single fork with no history, cheap model
1354
+ * const fork = await assistant.fork({ history: 'none', model: 'gpt-4o-mini' })
1355
+ * const answer = await fork.ask('Quick factual question')
1356
+ *
1357
+ * // Multiple forks
1358
+ * const [a, b] = await assistant.fork([
1359
+ * { history: 'none' },
1360
+ * { history: 3 },
1361
+ * ])
1362
+ * ```
1363
+ */
1364
+ async fork(options?: AssistantForkOptions): Promise<Assistant>
1365
+ async fork(options?: AssistantForkOptions[]): Promise<Assistant[]>
1366
+ async fork(options: AssistantForkOptions | AssistantForkOptions[] = {}): Promise<Assistant | Assistant[]> {
1367
+ if (Array.isArray(options)) {
1368
+ return Promise.all(options.map(o => this.fork(o)))
1369
+ }
1370
+
1371
+ if (!this.isStarted) {
1372
+ await this.start()
1373
+ }
1374
+
1375
+ // Separate assistant-level options from conversation-level options
1376
+ const { history: historyMode, forbidTools, allowTools, toolNames, onFork, ...convOverrides } = options
1377
+
1378
+ // Fork the conversation with history truncation
1379
+ const forkedConv = this.conversation.fork({ history: historyMode ?? 'full', ...convOverrides })
1380
+
1381
+ // Create a new assistant that reuses the forked conversation
1382
+ const forkedAssistant = this.container.feature('assistant', {
1383
+ ...this.options,
1384
+ // Pass through conversation overrides that map to assistant options
1385
+ ...(convOverrides.model ? { model: convOverrides.model } : {}),
1386
+ ...(convOverrides.maxTokens ? { maxTokens: convOverrides.maxTokens } : {}),
1387
+ ...(convOverrides.temperature != null ? { temperature: convOverrides.temperature } : {}),
1388
+ ...(convOverrides.topP != null ? { topP: convOverrides.topP } : {}),
1389
+ ...(convOverrides.topK != null ? { topK: convOverrides.topK } : {}),
1390
+ ...(convOverrides.frequencyPenalty != null ? { frequencyPenalty: convOverrides.frequencyPenalty } : {}),
1391
+ ...(convOverrides.presencePenalty != null ? { presencePenalty: convOverrides.presencePenalty } : {}),
1392
+ ...(convOverrides.stop ? { stop: convOverrides.stop } : {}),
1393
+ // Pass through tool filtering options
1394
+ ...(forbidTools ? { forbidTools } : {}),
1395
+ ...(allowTools ? { allowTools } : {}),
1396
+ ...(toolNames ? { toolNames } : {}),
1397
+ }) as Assistant
1398
+
1399
+ // Inject the forked conversation directly, bypassing the lazy getter
1400
+ forkedAssistant.state.set('conversation', forkedConv)
1401
+
1402
+ // Track fork depth so forks know they are forks
1403
+ forkedAssistant.state.set('forkDepth', this.forkDepth + 1)
1404
+
1405
+ // Clone interceptors so the fork behaves like the original
1406
+ forkedAssistant.interceptors.beforeAsk = this.interceptors.beforeAsk.clone()
1407
+ forkedAssistant.interceptors.beforeTurn = this.interceptors.beforeTurn.clone()
1408
+ forkedAssistant.interceptors.beforeToolCall = this.interceptors.beforeToolCall.clone()
1409
+ forkedAssistant.interceptors.afterToolCall = this.interceptors.afterToolCall.clone()
1410
+ forkedAssistant.interceptors.beforeResponse = this.interceptors.beforeResponse.clone()
1411
+
1412
+ // Copy system prompt extensions
1413
+ forkedAssistant.state.set('systemPromptExtensions', { ...this.systemPromptExtensions })
1414
+
1415
+ // Start wires up event forwarding and the interceptor-aware tool executor
1416
+ await forkedAssistant.start()
1417
+
1418
+ // Call the onFork hook if provided — lets callers customize the fork before use
1419
+ if (onFork) {
1420
+ await onFork(forkedAssistant, this)
1421
+ }
1422
+
1423
+ return forkedAssistant
1424
+ }
1425
+
1426
+ /** Active and completed research jobs, keyed by job entity ID. */
1427
+ readonly researchJobs = new Map<string, ResearchJob>()
1428
+
1429
+ /**
1430
+ * Create a non-blocking research job that fans out questions across forked assistants.
1431
+ * The forks fire immediately and the returned entity tracks progress via observable
1432
+ * state and events. Each fork preserves the full assistant identity (interceptors,
1433
+ * tools, hooks).
1434
+ *
1435
+ * @param prompt - Shared context/framing prompt prepended to each fork's system prompt
1436
+ * @param questions - Array of questions (strings) or objects with question + per-fork overrides
1437
+ * @param defaults - Default fork options applied to all forks
1438
+ * @returns A research job entity with observable state and events
1439
+ *
1440
+ * @example
1441
+ * ```typescript
1442
+ * // Fire and forget — check later
1443
+ * const job = await assistant.createResearchJob(
1444
+ * "Analyze this codebase for security issues",
1445
+ * ["Look for SQL injection", "Look for XSS", "Look for auth bypass"],
1446
+ * { history: 'none', model: 'gpt-4o-mini' }
1447
+ * )
1448
+ *
1449
+ * // Check progress
1450
+ * job.state.get('completed') // 2 of 3
1451
+ * job.state.get('results') // [answer1, answer2, null]
1452
+ *
1453
+ * // React to events
1454
+ * job.on('forkCompleted', (index, result) => console.log(`Fork ${index} done`))
1455
+ *
1456
+ * // Or just wait
1457
+ * await job.waitFor('completed')
1458
+ * ```
1459
+ */
1460
+ async createResearchJob(
1461
+ prompt: string,
1462
+ questions: (string | { question: string; forkOptions?: AssistantForkOptions })[],
1463
+ defaults: AssistantForkOptions = {}
1464
+ ): Promise<ResearchJob> {
1465
+ if (!this.isStarted) {
1466
+ await this.start()
1467
+ }
1468
+
1469
+ const jobId = `research:${this.container.utils.uuid()}`
1470
+ const total = questions.length
1471
+
1472
+ const job = this.container.entity<ResearchJobState, ResearchJobOptions, ResearchJobEvents>(
1473
+ jobId,
1474
+ { prompt, questions: questions.map(q => typeof q === 'string' ? q : q.question), forkOptions: defaults },
1475
+ ) as ResearchJob
1476
+
1477
+ job.setState({
1478
+ status: 'running',
1479
+ prompt,
1480
+ questions: questions.map(q => typeof q === 'string' ? q : q.question),
1481
+ results: new Array(total).fill(null),
1482
+ errors: new Array(total).fill(null),
1483
+ completed: 0,
1484
+ total,
1485
+ })
1486
+
1487
+ this.researchJobs.set(jobId, job)
1488
+
1489
+ // Build fork configs and create forks
1490
+ const forkConfigs = questions.map(q => ({
1491
+ ...defaults,
1492
+ ...(typeof q === 'string' ? {} : q.forkOptions),
1493
+ }))
1494
+
1495
+ const forks = await this.fork(forkConfigs)
1496
+
1497
+ // Apply shared prompt as a system prompt extension on each fork
1498
+ if (prompt) {
1499
+ for (const fork of forks) {
1500
+ fork.addSystemPromptExtension('researchPrompt', prompt)
1501
+ }
1502
+ }
1503
+
1504
+ // Fire all forks — don't await the batch, let them resolve individually
1505
+ for (let i = 0; i < forks.length; i++) {
1506
+ const fork = forks[i]!
1507
+ const q = questions[i]!
1508
+ const question = typeof q === 'string' ? q : q.question
1509
+
1510
+ fork.ask(question).then(
1511
+ (result) => {
1512
+ const results = [...job.state.get('results')!]
1513
+ results[i] = result
1514
+ const completed = job.state.get('completed')! + 1
1515
+
1516
+ job.setState({ results, completed })
1517
+ job.emit('forkCompleted', i, result)
1518
+
1519
+ if (completed === total) {
1520
+ job.setState({ status: 'completed' })
1521
+ job.emit('completed', results as string[])
1522
+ }
1523
+ },
1524
+ (err) => {
1525
+ const errors = [...job.state.get('errors')!]
1526
+ errors[i] = err?.message || String(err)
1527
+ const completed = job.state.get('completed')! + 1
1528
+
1529
+ job.setState({ errors, completed })
1530
+ job.emit('forkError', i, errors[i]!)
1531
+
1532
+ if (completed === total) {
1533
+ const results = job.state.get('results')!
1534
+ const hasAnyResult = results.some(r => r !== null)
1535
+ job.setState({ status: hasAnyResult ? 'completed' : 'failed' })
1536
+
1537
+ if (hasAnyResult) {
1538
+ job.emit('completed', results as string[])
1539
+ } else {
1540
+ job.emit('failed', errors)
1541
+ }
1542
+ }
1543
+ }
1544
+ )
1545
+ }
1546
+
1547
+ return job
1548
+ }
1549
+
1550
+ /**
1551
+ * Fan out N questions in parallel using forked assistants, return the results.
1552
+ * Sugar over createResearchJob — blocks until all forks complete.
1553
+ *
1554
+ * @param questions - Array of questions (strings) or objects with question + per-fork overrides
1555
+ * @param defaults - Default fork options applied to all forks
1556
+ * @returns Array of response strings, one per question
1557
+ *
1558
+ * @example
1559
+ * ```typescript
1560
+ * const results = await assistant.research([
1561
+ * "What are best practices for X?",
1562
+ * "What are common pitfalls of X?",
1563
+ * ], { history: 'none', model: 'gpt-4o-mini' })
1564
+ * ```
1565
+ */
1566
+ async research(
1567
+ questions: (string | { question: string; forkOptions?: AssistantForkOptions })[],
1568
+ defaults: AssistantForkOptions & { prompt?: string } = {}
1569
+ ): Promise<(string | null)[]> {
1570
+ const { prompt = '', ...forkDefaults } = defaults
1571
+ const job = await this.createResearchJob(prompt, questions, forkDefaults)
1572
+ await job.waitFor('completed')
1573
+ return job.state.get('results')!
1574
+ }
1575
+
1206
1576
  // -- Subagent API --
1207
1577
 
1208
1578
  /**