@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.
@@ -111,8 +111,20 @@ export const ConversationStateSchema = FeatureStateSchema.extend({
111
111
  callMaxTokens: z.number().nullable().describe('Per-call max tokens override, cleared after each ask()'),
112
112
  })
113
113
 
114
+ export class ConversationAbortError extends Error {
115
+ /** The partial text accumulated before the abort. */
116
+ readonly partial: string
117
+
118
+ constructor(partial: string) {
119
+ super('Conversation aborted')
120
+ this.name = 'ConversationAbortError'
121
+ this.partial = partial
122
+ }
123
+ }
124
+
114
125
  export const ConversationEventsSchema = FeatureEventsSchema.extend({
115
126
  userMessage: z.tuple([z.any().describe('The user message content (string or ContentPart[])')]).describe('Fired when a user message is added to the conversation'),
127
+ aborted: z.tuple([z.string().describe('Partial text accumulated before the abort')]).describe('Fired when the conversation is aborted mid-response'),
116
128
  turnStart: z.tuple([z.object({ turn: z.number(), isFollowUp: z.boolean() })]).describe('Fired at the start of each completion turn'),
117
129
  turnEnd: z.tuple([z.object({ turn: z.number(), hasToolCalls: z.boolean() })]).describe('Fired at the end of each completion turn'),
118
130
  toolCallsStart: z.tuple([z.any().describe('Array of tool call objects from the model')]).describe('Fired when the model begins a batch of tool calls'),
@@ -146,6 +158,16 @@ export type AskOptions = {
146
158
  schema?: z.ZodType
147
159
  }
148
160
 
161
+ export type ForkOptions = Omit<Partial<ConversationOptions>, 'history'> & {
162
+ /**
163
+ * Controls how much message history carries over to the fork.
164
+ * - `'full'` (default) — deep copy all messages
165
+ * - `'none'` — system prompt only, no chat history
166
+ * - `number` — keep system prompt + last N user/assistant exchanges
167
+ */
168
+ history?: 'full' | 'none' | number
169
+ }
170
+
149
171
  /**
150
172
  * Recursively set `additionalProperties: false` on every object-type node
151
173
  * in a JSON Schema tree. OpenAI strict mode requires this at every level.
@@ -212,6 +234,9 @@ export class Conversation extends Feature<ConversationState, ConversationOptions
212
234
  /** The active structured output schema for the current ask() call, if any. */
213
235
  private _activeSchema: z.ZodType | null = null
214
236
 
237
+ /** AbortController for the current ask() call, if any. */
238
+ private _abortController: AbortController | null = null
239
+
215
240
  /** Registered stubs: matched against user input to short-circuit the API with a canned response. */
216
241
  private _stubs: Array<{ matcher: string | RegExp; response: string | (() => string) }> = []
217
242
 
@@ -307,26 +332,129 @@ export class Conversation extends Feature<ConversationState, ConversationOptions
307
332
 
308
333
  /**
309
334
  * Fork the conversation into a new independent instance.
310
- * The fork inherits the same system prompt, tools, and full message history,
335
+ * The fork inherits the same system prompt, tools, and message history,
311
336
  * but has its own identity and state — changes in either direction do not affect the other.
312
337
  *
313
- * @param overrides - Optional option overrides for the forked conversation (e.g. different model or title)
338
+ * @param overrides - Option overrides for the forked conversation. Supports a `history` field
339
+ * that controls how much context carries over:
340
+ * - `'full'` (default) — deep copy all messages
341
+ * - `'none'` — system prompt only, no chat history
342
+ * - `number` — keep the system prompt plus the last N user/assistant exchanges
343
+ *
344
+ * When called with an array, creates multiple independent forks in one call.
314
345
  *
315
346
  * @example
316
347
  * ```typescript
348
+ * // Full context fork
317
349
  * const fork = conversation.fork()
318
- * await fork.ask('What if we took a different approach?')
319
- * // original conversation is unchanged
350
+ *
351
+ * // System prompt only — cheapest
352
+ * const lean = conversation.fork({ history: 'none', model: 'gpt-4o-mini' })
353
+ *
354
+ * // Last 3 exchanges + system prompt
355
+ * const recent = conversation.fork({ history: 3 })
356
+ *
357
+ * // Multiple forks at once
358
+ * const [a, b, c] = conversation.fork([
359
+ * { history: 'none' },
360
+ * { history: 'none' },
361
+ * { history: 5 },
362
+ * ])
320
363
  * ```
321
364
  */
322
- fork(overrides: Partial<ConversationOptions> = {}): Conversation {
323
- return this.container.feature('conversation', {
365
+ fork(overrides?: ForkOptions): Conversation
366
+ fork(overrides?: ForkOptions[]): Conversation[]
367
+ fork(overrides: ForkOptions | ForkOptions[] = {}): Conversation | Conversation[] {
368
+ if (Array.isArray(overrides)) {
369
+ return overrides.map(o => this.fork(o))
370
+ }
371
+
372
+ const { history: historyMode = 'full', ...convOverrides } = overrides
373
+ const allMessages = JSON.parse(JSON.stringify(this.messages)) as Message[]
374
+
375
+ let history: Message[]
376
+ if (historyMode === 'none') {
377
+ // System prompt only
378
+ const systemMsg = allMessages.find(m => m.role === 'system' || m.role === 'developer')
379
+ history = systemMsg ? [systemMsg] : []
380
+ } else if (historyMode === 'full') {
381
+ history = allMessages
382
+ } else {
383
+ // Keep last N exchanges (user + assistant pairs) plus system prompt
384
+ const systemMsg = allMessages.find(m => m.role === 'system' || m.role === 'developer')
385
+ const nonSystem = allMessages.filter(m => m.role !== 'system' && m.role !== 'developer')
386
+
387
+ // Walk backwards counting user messages as exchange boundaries.
388
+ // An exchange starts at a user message and includes everything after it
389
+ // until the next user message (assistant replies, tool calls, etc.).
390
+ let exchangeCount = 0
391
+ let cutoff = 0
392
+ for (let i = nonSystem.length - 1; i >= 0; i--) {
393
+ if (nonSystem[i]!.role === 'user') {
394
+ exchangeCount++
395
+ if (exchangeCount > historyMode) break
396
+ cutoff = i
397
+ }
398
+ }
399
+
400
+ const kept = nonSystem.slice(cutoff)
401
+ history = systemMsg ? [systemMsg, ...kept] : kept
402
+ }
403
+
404
+ const forked = this.container.feature('conversation', {
324
405
  ...this.options,
325
406
  id: undefined,
326
- history: JSON.parse(JSON.stringify(this.messages)),
407
+ history,
327
408
  tools: { ...this.tools },
328
- ...overrides,
409
+ ...convOverrides,
329
410
  })
411
+
412
+ // Copy stubs so forked conversations match the same patterns
413
+ ;(forked as any)._stubs = [...this._stubs]
414
+
415
+ return forked
416
+ }
417
+
418
+ /**
419
+ * Fan out N questions in parallel using forked conversations, return the results.
420
+ * Each fork is independent and ephemeral — no history is saved.
421
+ *
422
+ * @param questions - Array of questions (strings) or objects with question + per-fork overrides
423
+ * @param defaults - Default fork options applied to all forks (individual overrides take precedence)
424
+ * @returns Array of response strings, one per question
425
+ *
426
+ * @example
427
+ * ```typescript
428
+ * const results = await conversation.research([
429
+ * "What are the pros of approach A?",
430
+ * "What are the pros of approach B?",
431
+ * ], { history: 'none', model: 'gpt-4o-mini' })
432
+ *
433
+ * // Per-fork overrides
434
+ * const results = await conversation.research([
435
+ * "Quick factual question",
436
+ * { question: "Needs recent context", forkOptions: { history: 5 } },
437
+ * ], { history: 'none' })
438
+ * ```
439
+ */
440
+ async research(
441
+ questions: (string | { question: string; forkOptions?: ForkOptions })[],
442
+ defaults: ForkOptions = {}
443
+ ): Promise<string[]> {
444
+ const forkConfigs = questions.map(q => ({
445
+ ...defaults,
446
+ ...(typeof q === 'string' ? {} : q.forkOptions),
447
+ }))
448
+
449
+ const forks = this.fork(forkConfigs)
450
+
451
+ return Promise.all(
452
+ forks.map((fork, i) => {
453
+ const q = questions[i]!
454
+ const question = typeof q === 'string' ? q : q.question
455
+ return fork.ask(question)
456
+ })
457
+ )
330
458
  }
331
459
 
332
460
  /** Returns the OpenAI model name being used for completions. */
@@ -346,6 +474,16 @@ export class Conversation extends Feature<ConversationState, ConversationOptions
346
474
  return !!this.state.get('streaming')
347
475
  }
348
476
 
477
+ /**
478
+ * Abort the current ask() call. Cancels the in-flight network request and
479
+ * any pending tool executions. The ask() promise will reject with a
480
+ * ConversationAbortError whose `partial` property contains any text
481
+ * accumulated before the abort.
482
+ */
483
+ abort(): void {
484
+ this._abortController?.abort()
485
+ }
486
+
349
487
  /**
350
488
  * Returns the correct parameter name for limiting output tokens.
351
489
  * Local models (LM Studio, Ollama) and legacy OpenAI models use max_tokens.
@@ -544,6 +682,7 @@ export class Conversation extends Feature<ConversationState, ConversationOptions
544
682
  async ask(content: string | ContentPart[], options?: AskOptions): Promise<string> {
545
683
  this.state.set('callMaxTokens', options?.maxTokens ?? null)
546
684
  this._activeSchema = options?.schema ?? null
685
+ this._abortController = new AbortController()
547
686
 
548
687
  // Auto-compact before adding the new message
549
688
  if (this.options.autoCompact) {
@@ -603,9 +742,22 @@ export class Conversation extends Feature<ConversationState, ConversationOptions
603
742
  }
604
743
 
605
744
  return raw
745
+ } catch (err: any) {
746
+ if (err instanceof ConversationAbortError) {
747
+ this.emit('aborted', err.partial)
748
+ throw err
749
+ }
750
+ // Re-throw abort errors from the OpenAI SDK / DOM AbortController
751
+ if (err.name === 'AbortError' || this._abortController?.signal.aborted) {
752
+ const partial = this.state.get('lastResponse') || ''
753
+ this.emit('aborted', partial)
754
+ throw new ConversationAbortError(partial)
755
+ }
756
+ throw err
606
757
  } finally {
607
758
  this.state.set('callMaxTokens', null)
608
759
  this._activeSchema = null
760
+ this._abortController = null
609
761
  }
610
762
  }
611
763
 
@@ -898,7 +1050,7 @@ export class Conversation extends Feature<ConversationState, ConversationOptions
898
1050
  ...(this.options.presencePenalty != null ? { presence_penalty: this.options.presencePenalty } : {}),
899
1051
  ...(this.options.stop ? { stop: this.options.stop } : {}),
900
1052
  ...textFormat,
901
- })
1053
+ }, { signal: this._abortController?.signal })
902
1054
 
903
1055
  for await (const event of stream) {
904
1056
  this.emit('rawEvent', event)
@@ -914,6 +1066,7 @@ export class Conversation extends Feature<ConversationState, ConversationOptions
914
1066
  const delta = event.delta || ''
915
1067
  turnContent += delta
916
1068
  accumulated += delta
1069
+ this.state.set('lastResponse', accumulated)
917
1070
  this.emit('chunk', delta)
918
1071
  this.emit('preview', accumulated)
919
1072
  }
@@ -954,6 +1107,9 @@ export class Conversation extends Feature<ConversationState, ConversationOptions
954
1107
 
955
1108
  const functionOutputs: OpenAI.Responses.ResponseInputItem.FunctionCallOutput[] = []
956
1109
  for (const call of functionCalls) {
1110
+ if (this._abortController?.signal.aborted) {
1111
+ throw new ConversationAbortError(accumulated)
1112
+ }
957
1113
  const result = await this.executeTool(call.name, call.arguments || '{}')
958
1114
 
959
1115
  this.pushMessage({
@@ -1047,7 +1203,7 @@ export class Conversation extends Feature<ConversationState, ConversationOptions
1047
1203
  ...(this.options.presencePenalty != null ? { presence_penalty: this.options.presencePenalty } : {}),
1048
1204
  ...(this.options.stop ? { stop: this.options.stop } : {}),
1049
1205
  ...responseFormat,
1050
- })
1206
+ }, { signal: this._abortController?.signal })
1051
1207
 
1052
1208
  for await (const chunk of stream) {
1053
1209
  const delta = chunk.choices[0]?.delta
@@ -1055,6 +1211,7 @@ export class Conversation extends Feature<ConversationState, ConversationOptions
1055
1211
  if (delta?.content) {
1056
1212
  turnContent += delta.content
1057
1213
  accumulated += delta.content
1214
+ this.state.set('lastResponse', accumulated)
1058
1215
  this.emit('chunk', delta.content)
1059
1216
  this.emit('preview', accumulated)
1060
1217
  }
@@ -1105,6 +1262,9 @@ export class Conversation extends Feature<ConversationState, ConversationOptions
1105
1262
  this.emit('toolCallsStart', toolCalls)
1106
1263
 
1107
1264
  for (const tc of toolCalls) {
1265
+ if (this._abortController?.signal.aborted) {
1266
+ throw new ConversationAbortError(accumulated)
1267
+ }
1108
1268
  const result = await this.executeTool(tc.function.name, tc.function.arguments)
1109
1269
 
1110
1270
  const toolMessage: OpenAI.Chat.Completions.ChatCompletionToolMessageParam = {
@@ -1,5 +1,5 @@
1
1
  // Auto-generated bootstrap content
2
- // Generated at: 2026-04-06T20:39:10.358Z
2
+ // Generated at: 2026-04-09T05:21:44.176Z
3
3
  // Source: docs/bootstrap/*.md, docs/bootstrap/templates/*, docs/examples/*.md, docs/tutorials/*.md
4
4
  //
5
5
  // Do not edit manually. Run: luca build-bootstrap
@@ -1,4 +1,4 @@
1
1
  // Generated at compile time — do not edit manually
2
- export const BUILD_SHA = '8be7435'
2
+ export const BUILD_SHA = 'a96af4e'
3
3
  export const BUILD_BRANCH = 'main'
4
- export const BUILD_DATE = '2026-04-06T20:39:11Z'
4
+ export const BUILD_DATE = '2026-04-09T05:21:45Z'
package/src/helper.ts CHANGED
@@ -250,7 +250,7 @@ export abstract class Helper<T extends HelperState = HelperState, K extends Help
250
250
  * If a tool has no explicit handler but this instance has a method with
251
251
  * the same name, a handler is auto-generated that delegates to that method.
252
252
  */
253
- toTools(options?: { only?: string[], except?: string[] }): { schemas: Record<string, z.ZodType>, handlers: Record<string, Function> } {
253
+ toTools(options?: { only?: string[], except?: string[] }): { schemas: Record<string, z.ZodType>, handlers: Record<string, Function>, setup?: (consumer: Helper) => void } {
254
254
  // Walk the prototype chain collecting static tools (parent-first, child overwrites)
255
255
  const merged: Record<string, { schema: z.ZodType, description?: string, handler?: Function }> = {}
256
256
  const chain: Function[] = []
@@ -292,10 +292,19 @@ export abstract class Helper<T extends HelperState = HelperState, K extends Help
292
292
  }
293
293
  }
294
294
 
295
- return { schemas, handlers }
295
+ const result: { schemas: Record<string, z.ZodType>, handlers: Record<string, Function>, setup?: (consumer: Helper) => void } = { schemas, handlers }
296
+
297
+ // If this helper has a setupToolsConsumer override, package it as a setup
298
+ // function so consumers of toTools() can call it without needing the helper ref
299
+ const proto = Object.getPrototypeOf(this)
300
+ if (proto && proto.constructor !== Helper && typeof this.setupToolsConsumer === 'function' && this.setupToolsConsumer !== Helper.prototype.setupToolsConsumer) {
301
+ result.setup = (consumer: Helper) => this.setupToolsConsumer(consumer)
302
+ }
303
+
304
+ return result
296
305
  }
297
306
 
298
- /**
307
+ /**
299
308
  * The options passed to the helper when it was created.
300
309
  */
301
310
  get options() {