@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.
- package/assistants/codingAssistant/ABOUT.md +3 -1
- package/assistants/codingAssistant/CORE.md +2 -4
- package/assistants/codingAssistant/hooks.ts +9 -10
- package/assistants/codingAssistant/tools.ts +9 -0
- package/assistants/inkbot/ABOUT.md +13 -2
- package/assistants/inkbot/CORE.md +278 -39
- package/assistants/inkbot/hooks.ts +0 -8
- package/assistants/inkbot/tools.ts +24 -18
- package/assistants/researcher/ABOUT.md +5 -0
- package/assistants/researcher/CORE.md +46 -0
- package/assistants/researcher/hooks.ts +16 -0
- package/assistants/researcher/tools.ts +237 -0
- package/commands/inkbot.ts +526 -194
- package/docs/examples/assistant-hooks-reference.ts +171 -0
- package/package.json +1 -1
- package/public/slides-ai-native.html +902 -0
- package/public/slides-intro.html +974 -0
- package/src/agi/features/assistant.ts +432 -62
- package/src/agi/features/conversation.ts +170 -10
- package/src/bootstrap/generated.ts +1 -1
- package/src/cli/build-info.ts +2 -2
- package/src/helper.ts +12 -3
- package/src/introspection/generated.agi.ts +1105 -873
- package/src/introspection/generated.node.ts +757 -757
- package/src/introspection/generated.web.ts +1 -1
- package/src/python/generated.ts +1 -1
- package/src/scaffolds/generated.ts +1 -1
- package/test/assistant-hooks.test.ts +306 -0
- package/test/assistant.test.ts +1 -1
- package/test/fork-and-research.test.ts +450 -0
- package/SPEC.md +0 -304
|
@@ -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
|
|
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 -
|
|
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
|
-
*
|
|
319
|
-
* //
|
|
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
|
|
323
|
-
|
|
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
|
|
407
|
+
history,
|
|
327
408
|
tools: { ...this.tools },
|
|
328
|
-
...
|
|
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-
|
|
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
|
package/src/cli/build-info.ts
CHANGED
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
|
-
|
|
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() {
|