nebula-ai-core 0.1.0

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 (109) hide show
  1. package/README.md +24 -0
  2. package/package.json +69 -0
  3. package/src/brain/compaction.ts +131 -0
  4. package/src/brain/frozen-prefix.ts +320 -0
  5. package/src/brain/history-persist.ts +154 -0
  6. package/src/brain/index.ts +43 -0
  7. package/src/brain/openai-brain.ts +533 -0
  8. package/src/brain/sanitize.ts +23 -0
  9. package/src/brain/stub.ts +20 -0
  10. package/src/brain/types.ts +129 -0
  11. package/src/chain.ts +75 -0
  12. package/src/claude-plugins/discovery.ts +152 -0
  13. package/src/claude-plugins/index.ts +6 -0
  14. package/src/claude-plugins/types.ts +38 -0
  15. package/src/commands/index.ts +16 -0
  16. package/src/commands/registry.ts +255 -0
  17. package/src/config.ts +213 -0
  18. package/src/economy/index.ts +6 -0
  19. package/src/events/index.ts +4 -0
  20. package/src/events/listeners.ts +37 -0
  21. package/src/events/queue.ts +63 -0
  22. package/src/events/router.ts +42 -0
  23. package/src/events/types.ts +28 -0
  24. package/src/format.ts +12 -0
  25. package/src/identity/agent-card.ts +110 -0
  26. package/src/identity/deployments.ts +20 -0
  27. package/src/identity/erc8004.ts +161 -0
  28. package/src/identity/index.ts +29 -0
  29. package/src/identity/keystore-blob.ts +60 -0
  30. package/src/identity/receipt.ts +27 -0
  31. package/src/identity/stub.ts +29 -0
  32. package/src/identity/types.ts +20 -0
  33. package/src/index.ts +372 -0
  34. package/src/locks.ts +233 -0
  35. package/src/mcp/discovery.ts +150 -0
  36. package/src/mcp/index.ts +10 -0
  37. package/src/mcp/manager.ts +110 -0
  38. package/src/mcp/stdio-client.ts +154 -0
  39. package/src/mcp/types.ts +44 -0
  40. package/src/memory/edit.ts +53 -0
  41. package/src/memory/encryption.ts +88 -0
  42. package/src/memory/fs-util.ts +15 -0
  43. package/src/memory/index-file.ts +74 -0
  44. package/src/memory/index-sync.ts +99 -0
  45. package/src/memory/index.ts +58 -0
  46. package/src/memory/list-tool.ts +105 -0
  47. package/src/memory/pack-blob.ts +120 -0
  48. package/src/memory/pack-gather.ts +112 -0
  49. package/src/memory/parser.ts +20 -0
  50. package/src/memory/read-tool.ts +198 -0
  51. package/src/memory/save-tool.ts +189 -0
  52. package/src/memory/scan.ts +63 -0
  53. package/src/memory/topic.ts +32 -0
  54. package/src/memory/types.ts +49 -0
  55. package/src/migration/index.ts +6 -0
  56. package/src/migration/option3-crypto.ts +127 -0
  57. package/src/operator/index.ts +9 -0
  58. package/src/operator/keychain.ts +53 -0
  59. package/src/operator/keystore-file.ts +33 -0
  60. package/src/operator/privkey-base.ts +60 -0
  61. package/src/operator/raw-privkey.ts +39 -0
  62. package/src/operator/signer.ts +46 -0
  63. package/src/operator/walletconnect.ts +454 -0
  64. package/src/pairing.ts +285 -0
  65. package/src/paths.ts +70 -0
  66. package/src/permission/dangerous.ts +108 -0
  67. package/src/permission/env-redact.ts +54 -0
  68. package/src/permission/index.ts +16 -0
  69. package/src/permission/path-guard.ts +114 -0
  70. package/src/permission/service.ts +191 -0
  71. package/src/plugins/context.ts +225 -0
  72. package/src/plugins/hooks.ts +81 -0
  73. package/src/plugins/index.ts +24 -0
  74. package/src/plugins/tool-search.ts +49 -0
  75. package/src/public/card.ts +67 -0
  76. package/src/runtime/activity.ts +29 -0
  77. package/src/runtime/index.ts +2 -0
  78. package/src/runtime/runtime.ts +113 -0
  79. package/src/sandbox/credentials.ts +25 -0
  80. package/src/sandbox/docker.ts +396 -0
  81. package/src/sandbox/factory.ts +99 -0
  82. package/src/sandbox/index.ts +15 -0
  83. package/src/sandbox/linux.ts +141 -0
  84. package/src/sandbox/local.ts +19 -0
  85. package/src/sandbox/macos.ts +71 -0
  86. package/src/sandbox/seatbelt-profile.ts +139 -0
  87. package/src/sandbox/types.ts +129 -0
  88. package/src/skills/index.ts +8 -0
  89. package/src/skills/scanner.ts +257 -0
  90. package/src/skills/triggers.ts +78 -0
  91. package/src/skills/types.ts +37 -0
  92. package/src/storage/encryption.ts +87 -0
  93. package/src/storage/factory.ts +31 -0
  94. package/src/storage/index.ts +11 -0
  95. package/src/storage/local-stub.ts +70 -0
  96. package/src/storage/sqlite.ts +95 -0
  97. package/src/storage/types.ts +21 -0
  98. package/src/tools/escalation.ts +200 -0
  99. package/src/tools/index.ts +11 -0
  100. package/src/tools/registry.ts +152 -0
  101. package/src/tools/types.ts +65 -0
  102. package/src/tools/zod-helpers.ts +36 -0
  103. package/src/tools/zod-schema.ts +99 -0
  104. package/src/wallet/drain.ts +79 -0
  105. package/src/wallet/eoa.ts +51 -0
  106. package/src/wallet/index.ts +47 -0
  107. package/src/wallet/keystore.ts +50 -0
  108. package/src/wallet/operator-keystore-crypto.ts +530 -0
  109. package/src/wallet/operator-session.ts +344 -0
@@ -0,0 +1,533 @@
1
+ /**
2
+ * Provider-agnostic, OpenAI-compatible brain for Nebula.
3
+ *
4
+ * Replaces the legacy decentralized-compute brain. Talks to any endpoint that
5
+ * speaks the OpenAI `/chat/completions` shape via a simple `Authorization:
6
+ * Bearer` header, so the same adapter serves OpenAI (default GPT-4o-mini),
7
+ * Z.AI (GLM), Tencent Hunyuan, or any other compatible gateway by changing
8
+ * `baseUrl` / `model` / `apiKey` — no code change.
9
+ *
10
+ * The infer/compaction/tool-loop logic is intentionally identical to the
11
+ * harness's prior brain; only the transport (auth + endpoint) differs.
12
+ */
13
+ import type { ToolSchema } from '../tools/types'
14
+ import {
15
+ type CompactionOpts,
16
+ DEFAULT_COMPACTION_OPTS,
17
+ SUMMARY_SYSTEM_PROMPT,
18
+ compactHistory,
19
+ estimateTokens,
20
+ shouldCompact,
21
+ } from './compaction'
22
+ import { type FrozenPrefix, renderFrozenPrefix, renderUserContext } from './frozen-prefix'
23
+ import type { HistoryPersist } from './history-persist'
24
+ import { sanitizeDashes } from './sanitize'
25
+ import type { Brain, BrainInferInput, BrainMessage, BrainTurn } from './types'
26
+
27
+ /** Channel key used when none is specified — preserves single-history behavior. */
28
+ export const DEFAULT_CHANNEL_KEY = 'default'
29
+
30
+ /** Default cap on assistant output tokens per turn. */
31
+ export const DEFAULT_MAX_OUTPUT_TOKENS = 4096
32
+
33
+ /** Default OpenAI-compatible endpoint and model when not overridden. */
34
+ export const DEFAULT_BASE_URL = 'https://api.openai.com/v1'
35
+ export const DEFAULT_MODEL = 'gpt-4o-mini'
36
+
37
+ export interface OpenAIBrainOpts {
38
+ /** OpenAI-compatible base URL, e.g. https://api.openai.com/v1 (default) or a Z.AI/Tencent gateway. */
39
+ baseUrl?: string
40
+ /** Bearer API key for the endpoint. */
41
+ apiKey: string
42
+ /** Model id, e.g. gpt-4o-mini (default), glm-4.6, hunyuan-*. */
43
+ model?: string
44
+ tools: ToolSchema[]
45
+ prefix: FrozenPrefix
46
+ /** Seed history for the legacy single-history (`'default'`) channel. */
47
+ history?: BrainMessage[]
48
+ /** Default 4096. */
49
+ maxOutputTokens?: number
50
+ /** Pre-flight auto-compaction config. Omit for defaults; pass `null` to disable. */
51
+ compaction?: CompactionOpts | null
52
+ /** Optional persistence handle for channel histories. */
53
+ persist?: HistoryPersist
54
+ onToolCall?: (call: { id: string; name: string; args: unknown }) => Promise<BrainMessage>
55
+ }
56
+
57
+ export class OpenAIBrain implements Brain {
58
+ private readonly baseUrl: string
59
+ private readonly apiKey: string
60
+ private readonly model: string
61
+ private ready = false
62
+ private readonly histories = new Map<string, BrainMessage[]>()
63
+ private readonly lastUsage = new Map<string, BrainTurn['usage']>()
64
+ private readonly renderedPrefix: string
65
+ private userContextText: string | null
66
+ private persistHydrated = false
67
+
68
+ constructor(private readonly opts: OpenAIBrainOpts) {
69
+ this.baseUrl = (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, '')
70
+ this.apiKey = opts.apiKey
71
+ this.model = opts.model ?? DEFAULT_MODEL
72
+ if (opts.history && opts.history.length > 0) {
73
+ this.histories.set(DEFAULT_CHANNEL_KEY, [...opts.history])
74
+ }
75
+ this.renderedPrefix = renderFrozenPrefix(opts.prefix)
76
+ this.userContextText = renderUserContext(opts.prefix)
77
+ }
78
+
79
+ /** Refresh the per-turn user-context payload (MEMORY.md etc.) without rebuilding the prompt. */
80
+ refreshUserContext(prefix: FrozenPrefix): void {
81
+ this.userContextText = renderUserContext(prefix)
82
+ }
83
+
84
+ async init(): Promise<void> {
85
+ if (this.ready) return
86
+ this.ready = true
87
+ await this.hydrateFromPersist()
88
+ }
89
+
90
+ private async hydrateFromPersist(): Promise<void> {
91
+ if (this.persistHydrated || !this.opts.persist) return
92
+ this.persistHydrated = true
93
+ try {
94
+ const loaded = await this.opts.persist.loadAll()
95
+ for (const [key, history] of loaded) {
96
+ if (this.histories.has(key) && (this.histories.get(key)?.length ?? 0) > 0) continue
97
+ this.histories.set(key, [...history])
98
+ }
99
+ } catch {
100
+ // Persist load failures must never block brain startup.
101
+ }
102
+ }
103
+
104
+ getChannelHistory(channelKey: string = DEFAULT_CHANNEL_KEY): readonly BrainMessage[] {
105
+ return [...(this.histories.get(channelKey) ?? [])]
106
+ }
107
+
108
+ setChannelHistory(channelKey: string, history: BrainMessage[]): void {
109
+ this.histories.set(channelKey, [...history])
110
+ }
111
+
112
+ async clearChannel(channelKey: string = DEFAULT_CHANNEL_KEY): Promise<void> {
113
+ this.histories.set(channelKey, [])
114
+ this.lastUsage.delete(channelKey)
115
+ if (this.opts.persist) {
116
+ try {
117
+ await this.opts.persist.clearChannel(channelKey)
118
+ } catch {
119
+ // best-effort
120
+ }
121
+ }
122
+ }
123
+
124
+ listChannels(): string[] {
125
+ const out: string[] = []
126
+ for (const [k, v] of this.histories) {
127
+ if (v.length > 0) out.push(k)
128
+ }
129
+ return out
130
+ }
131
+
132
+ private getOrCreateHistory(channelKey: string): BrainMessage[] {
133
+ let h = this.histories.get(channelKey)
134
+ if (!h) {
135
+ h = []
136
+ this.histories.set(channelKey, h)
137
+ }
138
+ return h
139
+ }
140
+
141
+ async infer(input: BrainInferInput): Promise<BrainTurn> {
142
+ if (!this.ready) await this.init()
143
+ const signal = input.signal
144
+ if (signal?.aborted) {
145
+ throw new DOMException('aborted before infer started', 'AbortError')
146
+ }
147
+ const channelKey = input.channelKey ?? DEFAULT_CHANNEL_KEY
148
+ await this.maybeCompact(channelKey, input)
149
+
150
+ const history = this.getOrCreateHistory(channelKey)
151
+ const userText = normalizeUserContent(input)
152
+ const messages: BrainMessage[] = [{ role: 'system', content: this.renderedPrefix }, ...history]
153
+ if (this.userContextText) {
154
+ messages.push({ role: 'user', content: this.userContextText })
155
+ }
156
+ messages.push({ role: 'user', content: userText })
157
+
158
+ let turnResult: BrainTurn | null = null
159
+ let recoveredFromSafetyBlock = false
160
+ while (true) {
161
+ if (signal?.aborted) {
162
+ throw new DOMException('aborted between round-trips', 'AbortError')
163
+ }
164
+ const resp = await this.callCompletion(messages, signal)
165
+ turnResult = resp
166
+
167
+ if (!resp.toolCalls.length) {
168
+ const blockedName = detectBlockedToolError(resp.content ?? '')
169
+ if (blockedName && !recoveredFromSafetyBlock) {
170
+ recoveredFromSafetyBlock = true
171
+ const validNames = this.opts.tools
172
+ .map(t => (t as { name?: string }).name ?? '')
173
+ .filter(n => n.startsWith(`${blockedName}.`) || n.startsWith(`${blockedName}_`))
174
+ .slice(0, 12)
175
+ const hint =
176
+ validNames.length > 0
177
+ ? `Your last tool call used the bare name "${blockedName}", which is not a registered tool. Use the full name with subname (one of: ${validNames.join(', ')}). Retry now.`
178
+ : `Your last tool call used the bare name "${blockedName}", which is not a registered tool. Use the full namespaced name (e.g., something.action). Retry now.`
179
+ messages.push({ role: 'user', content: hint })
180
+ continue
181
+ }
182
+ messages.push({ role: 'assistant', content: resp.content ?? '' })
183
+ break
184
+ }
185
+
186
+ messages.push({
187
+ role: 'assistant',
188
+ content: resp.content ?? '',
189
+ toolCalls: resp.toolCalls,
190
+ })
191
+
192
+ for (const call of resp.toolCalls) {
193
+ if (signal?.aborted) {
194
+ throw new DOMException('aborted between tool calls', 'AbortError')
195
+ }
196
+ const isMalformed =
197
+ !call.name ||
198
+ (typeof call.args === 'string' &&
199
+ call.args !== '' &&
200
+ !looksLikeValidJsonString(call.args))
201
+ if (isMalformed) {
202
+ const toolLabel = call.name || MALFORMED_TOOL_LABEL
203
+ if (input.onToolEvent) {
204
+ try {
205
+ input.onToolEvent({
206
+ kind: 'start',
207
+ tool: toolLabel,
208
+ callId: call.id,
209
+ argsPreview: previewToolArgs(call.args),
210
+ })
211
+ input.onToolEvent({ kind: 'end', tool: toolLabel, callId: call.id, ok: false })
212
+ } catch {
213
+ /* swallow */
214
+ }
215
+ }
216
+ messages.push({
217
+ role: 'tool',
218
+ toolCallId: call.id,
219
+ content: JSON.stringify({
220
+ error:
221
+ 'Tool call envelope was malformed (empty name or truncated arguments). Re-emit with a complete tool name and a parseable JSON args object.',
222
+ }),
223
+ })
224
+ continue
225
+ }
226
+ if (!this.opts.onToolCall) {
227
+ messages.push({
228
+ role: 'tool',
229
+ toolCallId: call.id,
230
+ content: JSON.stringify({ error: 'Tool handler not wired' }),
231
+ })
232
+ continue
233
+ }
234
+ if (input.onToolEvent) {
235
+ try {
236
+ input.onToolEvent({
237
+ kind: 'start',
238
+ tool: call.name,
239
+ callId: call.id,
240
+ argsPreview: previewToolArgs(call.args),
241
+ })
242
+ } catch {
243
+ /* observer errors must never block tool execution */
244
+ }
245
+ }
246
+ const toolMsg = await this.opts.onToolCall(call)
247
+ if (input.onToolEvent) {
248
+ try {
249
+ input.onToolEvent({
250
+ kind: 'end',
251
+ tool: call.name,
252
+ callId: call.id,
253
+ ok: inferToolOk(toolMsg.content ?? ''),
254
+ })
255
+ } catch {
256
+ /* swallow */
257
+ }
258
+ }
259
+ messages.push({ ...toolMsg, toolCallId: call.id })
260
+ }
261
+ }
262
+
263
+ const finalAssistant = findLastAssistantContent(messages)
264
+ const userMsg: BrainMessage = { role: 'user', content: userText }
265
+ const assistantMsg: BrainMessage = { role: 'assistant', content: finalAssistant }
266
+ history.push(userMsg)
267
+ history.push(assistantMsg)
268
+
269
+ if (turnResult?.usage) this.lastUsage.set(channelKey, turnResult.usage)
270
+
271
+ if (this.opts.persist) {
272
+ try {
273
+ await this.opts.persist.appendTurn(channelKey, userMsg, assistantMsg)
274
+ } catch {
275
+ // Persist failure is non-fatal for the live turn.
276
+ }
277
+ }
278
+
279
+ if (turnResult?.content) {
280
+ turnResult.content = sanitizeDashes(turnResult.content)
281
+ }
282
+ return turnResult ?? { content: null, toolCalls: [] }
283
+ }
284
+
285
+ private async maybeCompact(channelKey: string, input: BrainInferInput): Promise<void> {
286
+ if (this.opts.compaction === null) return
287
+ const cfg = this.opts.compaction ?? DEFAULT_COMPACTION_OPTS
288
+ const history = this.histories.get(channelKey)
289
+ if (!history || history.length === 0) return
290
+ const lastUsage = this.lastUsage.get(channelKey)
291
+ const trigger = shouldCompact(history, lastUsage?.promptTokens ?? null, cfg)
292
+ if (trigger == null) return
293
+ let compacted: BrainMessage[]
294
+ try {
295
+ compacted = await compactHistory(history, cfg, async older => this.summarizeOlder(older))
296
+ } catch {
297
+ return
298
+ }
299
+ if (compacted.length >= history.length) return
300
+ this.histories.set(channelKey, compacted)
301
+ this.lastUsage.delete(channelKey)
302
+ if (this.opts.persist) {
303
+ try {
304
+ await this.opts.persist.rewriteChannel(channelKey, compacted)
305
+ } catch {
306
+ // best-effort
307
+ }
308
+ }
309
+ if (input.onCompactionEvent) {
310
+ try {
311
+ input.onCompactionEvent({
312
+ channelKey,
313
+ from: history.length,
314
+ to: compacted.length,
315
+ promptTokens: trigger,
316
+ })
317
+ } catch {
318
+ /* observer errors swallowed */
319
+ }
320
+ }
321
+ }
322
+
323
+ private async summarizeOlder(older: readonly BrainMessage[]): Promise<string> {
324
+ const flat = older
325
+ .map(m => {
326
+ const tag = m.role.toUpperCase()
327
+ if (m.toolCalls && m.toolCalls.length > 0) {
328
+ const calls = m.toolCalls
329
+ .map(
330
+ tc =>
331
+ `${tc.name}(${typeof tc.args === 'string' ? tc.args : JSON.stringify(tc.args ?? {})})`,
332
+ )
333
+ .join(' | ')
334
+ return `${tag}: ${m.content || ''}\n[TOOL_CALLS] ${calls}`
335
+ }
336
+ return `${tag}: ${m.content || ''}`
337
+ })
338
+ .join('\n\n')
339
+ const body = {
340
+ model: this.model,
341
+ messages: [
342
+ { role: 'system', content: SUMMARY_SYSTEM_PROMPT },
343
+ { role: 'user', content: flat },
344
+ ],
345
+ max_tokens: 1024,
346
+ }
347
+ const resp = await fetch(`${this.baseUrl}/chat/completions`, {
348
+ method: 'POST',
349
+ headers: this.headers(),
350
+ body: JSON.stringify(body),
351
+ })
352
+ if (!resp.ok) {
353
+ throw new Error(`Compaction summarize HTTP ${resp.status}`)
354
+ }
355
+ const json = (await resp.json()) as {
356
+ choices: Array<{ message: { content?: string | null } }>
357
+ }
358
+ return (json.choices[0]?.message.content ?? '').trim()
359
+ }
360
+
361
+ private headers(): Record<string, string> {
362
+ return {
363
+ 'Content-Type': 'application/json',
364
+ Authorization: `Bearer ${this.apiKey}`,
365
+ }
366
+ }
367
+
368
+ private async callCompletion(messages: BrainMessage[], signal?: AbortSignal): Promise<BrainTurn> {
369
+ const body: Record<string, unknown> = {
370
+ model: this.model,
371
+ messages: messages.map(m => {
372
+ if (m.role === 'tool') {
373
+ return { role: 'tool', tool_call_id: m.toolCallId, content: m.content }
374
+ }
375
+ if (m.role === 'assistant' && m.toolCalls && m.toolCalls.length > 0) {
376
+ return {
377
+ role: 'assistant',
378
+ content: m.content || null,
379
+ tool_calls: m.toolCalls.map(tc => ({
380
+ id: tc.id,
381
+ type: 'function',
382
+ function: {
383
+ name: tc.name,
384
+ arguments: typeof tc.args === 'string' ? tc.args : JSON.stringify(tc.args),
385
+ },
386
+ })),
387
+ }
388
+ }
389
+ return { role: m.role, content: m.content }
390
+ }),
391
+ max_tokens: this.opts.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS,
392
+ }
393
+ if (this.opts.tools.length > 0) {
394
+ body.tools = this.opts.tools
395
+ body.tool_choice = 'auto'
396
+ }
397
+ const resp = await fetch(`${this.baseUrl}/chat/completions`, {
398
+ method: 'POST',
399
+ headers: this.headers(),
400
+ body: JSON.stringify(body),
401
+ signal,
402
+ })
403
+ if (!resp.ok) {
404
+ const text = await resp.text()
405
+ throw new Error(`Brain HTTP ${resp.status}: ${text}`)
406
+ }
407
+ const json = (await resp.json()) as {
408
+ choices: Array<{
409
+ finish_reason?: string
410
+ message: {
411
+ content?: string | null
412
+ tool_calls?: Array<{ id: string; function: { name: string; arguments: string } }>
413
+ reasoning_content?: string
414
+ }
415
+ }>
416
+ usage?: {
417
+ prompt_tokens?: number
418
+ completion_tokens?: number
419
+ total_tokens?: number
420
+ prompt_tokens_details?: { cached_tokens?: number }
421
+ }
422
+ }
423
+ const choice = json.choices[0]!
424
+ const msg = choice.message
425
+ const rawContent = msg.content
426
+ const reasoning = msg.reasoning_content
427
+ const fallbackFromReasoning =
428
+ !rawContent && reasoning && reasoning.length > 0 ? stripThinkBlocks(reasoning) : null
429
+ return {
430
+ content: rawContent ? rawContent : fallbackFromReasoning,
431
+ toolCalls: (msg.tool_calls ?? []).map(tc => ({
432
+ id: tc.id,
433
+ name: tc.function.name,
434
+ args: safeParseJson(tc.function.arguments),
435
+ })),
436
+ reasoningContent: msg.reasoning_content,
437
+ finishReason: choice.finish_reason,
438
+ usage: {
439
+ promptTokens: json.usage?.prompt_tokens,
440
+ completionTokens: json.usage?.completion_tokens,
441
+ totalTokens: json.usage?.total_tokens,
442
+ cachedTokens: json.usage?.prompt_tokens_details?.cached_tokens,
443
+ },
444
+ }
445
+ }
446
+ }
447
+
448
+ function normalizeUserContent(input: BrainInferInput): string {
449
+ const d = input.event.payload.data
450
+ if (typeof d === 'string') return d
451
+ return JSON.stringify(d)
452
+ }
453
+
454
+ function safeParseJson(raw: string): unknown {
455
+ try {
456
+ return JSON.parse(raw)
457
+ } catch {
458
+ return raw
459
+ }
460
+ }
461
+
462
+ export function looksLikeValidJsonString(raw: string): boolean {
463
+ if (!raw || raw.length === 0) return true
464
+ try {
465
+ JSON.parse(raw)
466
+ return true
467
+ } catch {
468
+ return false
469
+ }
470
+ }
471
+
472
+ const THINK_BLOCK_RE = /<think>[\s\S]*?<\/think>/g
473
+ const MALFORMED_TOOL_LABEL = '<malformed>'
474
+
475
+ export function stripThinkBlocks(text: string): string {
476
+ if (!text) return text
477
+ return text.replace(THINK_BLOCK_RE, '').trim()
478
+ }
479
+
480
+ export function detectBlockedToolError(content: string): string | null {
481
+ if (!content) return null
482
+ const m = content.match(/Unauthorized:\s+(\S+)\s+is a blocked tool/)
483
+ return m ? m[1]! : null
484
+ }
485
+
486
+ function findLastAssistantContent(messages: BrainMessage[]): string {
487
+ for (let i = messages.length - 1; i >= 0; i--) {
488
+ const m = messages[i]
489
+ if (m && m.role === 'assistant') return m.content
490
+ }
491
+ return ''
492
+ }
493
+
494
+ export function previewToolArgs(args: unknown): string {
495
+ if (args == null) return ''
496
+ if (typeof args === 'string') return truncatePreview(args)
497
+ if (Array.isArray(args)) return `[${args.length}]`
498
+ if (typeof args === 'object') {
499
+ const o = args as Record<string, unknown>
500
+ const keys = Object.keys(o)
501
+ if (keys.length === 0) return ''
502
+ for (const k of ['url', 'path', 'command', 'query', 'name', 'address']) {
503
+ const v = o[k]
504
+ if (typeof v === 'string' && v.length > 0) return truncatePreview(`${k}=${v}`)
505
+ }
506
+ return truncatePreview(keys.join(','))
507
+ }
508
+ try {
509
+ return truncatePreview(String(args))
510
+ } catch {
511
+ return ''
512
+ }
513
+ }
514
+
515
+ function truncatePreview(s: string): string {
516
+ const max = 60
517
+ if (s.length <= max) return s
518
+ return `${s.slice(0, max - 1)}…`
519
+ }
520
+
521
+ export function inferToolOk(content: string): boolean {
522
+ if (!content) return true
523
+ try {
524
+ const o = JSON.parse(content) as Record<string, unknown>
525
+ if (typeof o.ok === 'boolean') return o.ok
526
+ if (typeof o.error === 'string' && o.error.length > 0) return false
527
+ return true
528
+ } catch {
529
+ return !content.toLowerCase().includes('error')
530
+ }
531
+ }
532
+
533
+ export { estimateTokens }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Backstop sanitizer for brain output.
3
+ *
4
+ * The frozen-prefix system prompt forbids em-dashes (U+2014) and en-dashes
5
+ * (U+2013). Weak models (qwen3.6-plus, nebula's flagship) occasionally slip
6
+ * despite the rule. This sanitizer is the final filter on brain output,
7
+ * applied at the single brain return point in og-compute.ts so every
8
+ * surface (TUI, TG, A2A, market) sees clean text.
9
+ *
10
+ * Replacements were chosen to preserve readability with minimal punctuation
11
+ * disruption:
12
+ * - U+2014 em-dash → comma + space ("X — Y" → "X, Y")
13
+ * - U+2013 en-dash → ASCII hyphen ("3–5" → "3-5")
14
+ *
15
+ * Stand-alone hyphen-substitution avoids accidentally turning a number
16
+ * range into prose-only ("3 to 5" feels heavy-handed in code/numeric
17
+ * contexts), while comma substitution for em-dash keeps the prose rhythm
18
+ * the model intended.
19
+ */
20
+ export function sanitizeDashes(text: string): string {
21
+ if (!text) return text
22
+ return text.replace(/—/g, ', ').replace(/–/g, '-')
23
+ }
@@ -0,0 +1,20 @@
1
+ import type { Brain, BrainInferInput, BrainTurn } from './types'
2
+
3
+ /**
4
+ * Echo brain for phase 1 — takes the event payload text and returns it
5
+ * verbatim. Lets us wire and test the runtime loop before the real Mantle
6
+ * Compute integration lands in phase 3.
7
+ */
8
+ export class StubBrain implements Brain {
9
+ async infer(input: BrainInferInput): Promise<BrainTurn> {
10
+ const text =
11
+ typeof input.event.payload.data === 'string'
12
+ ? input.event.payload.data
13
+ : JSON.stringify(input.event.payload.data)
14
+ return {
15
+ content: `[stub-brain echo] ${text}`,
16
+ toolCalls: [],
17
+ finishReason: 'stop',
18
+ }
19
+ }
20
+ }
@@ -0,0 +1,129 @@
1
+ import type { NebulaEvent } from '../events/types'
2
+ import type { ToolCall, ToolSchema } from '../tools/types'
3
+
4
+ export interface BrainMessage {
5
+ role: 'system' | 'user' | 'assistant' | 'tool'
6
+ content: string
7
+ /** Required on `tool` role: the id of the assistant tool_call this responds to. */
8
+ toolCallId?: string
9
+ /**
10
+ * Required on `assistant` role messages that issued tool_calls. Without this
11
+ * the next round-trip's `tool` message has no preceding `tool_calls` to
12
+ * reference, and the OpenAI-compat endpoint rejects with HTTP 400
13
+ * "messages with role 'tool' must be a response to a preceeding message
14
+ * with 'tool_calls'".
15
+ */
16
+ toolCalls?: Array<{ id: string; name: string; args: unknown }>
17
+ }
18
+
19
+ /**
20
+ * Per-tool-call lifecycle event surfaced to the dispatcher for UI rendering.
21
+ * Distinct from the brain-construction `onToolCall` (which actually EXECUTES
22
+ * the tool); this is fire-and-forget for "show what the agent is doing right
23
+ * now" surfaces (TG progress message, future TUI bridge, etc.).
24
+ *
25
+ * Errors thrown by the observer are swallowed by the brain.
26
+ */
27
+ export interface BrainToolEvent {
28
+ /** 'start' fires BEFORE tool execution; 'end' fires AFTER. */
29
+ kind: 'start' | 'end'
30
+ /** Fully-qualified tool name, e.g. `shell.run`. */
31
+ tool: string
32
+ /** Tool-call id; correlates start ↔ end pair within the same turn. */
33
+ callId: string
34
+ /** Short stringified args preview (≤ ~80 chars). Present on 'start'. */
35
+ argsPreview?: string
36
+ /** Tool execution success. Heuristic from result content. Present on 'end'. */
37
+ ok?: boolean
38
+ }
39
+
40
+ /**
41
+ * Compaction event surfaced when the brain auto-folds older history into a
42
+ * summary message. Subscribers (TUI primarily) use this to push a system row
43
+ * so the operator knows the summary fired. Errors thrown by the observer are
44
+ * swallowed by the brain.
45
+ */
46
+ export interface BrainCompactionEvent {
47
+ /** Channel whose history was compacted. */
48
+ channelKey: string
49
+ /** Number of messages BEFORE compaction (full history length). */
50
+ from: number
51
+ /** Number of messages AFTER compaction (summary + kept recent). */
52
+ to: number
53
+ /** Token estimate of the pre-compaction history. */
54
+ promptTokens: number
55
+ }
56
+
57
+ export interface BrainInferInput {
58
+ /** The event that woke the brain. */
59
+ event: NebulaEvent
60
+ /** Optional multi-turn context beyond the event payload. */
61
+ history?: BrainMessage[]
62
+ /** Optional tool allowlist override (defaults to all registered tools). */
63
+ toolWhitelist?: string[]
64
+ /**
65
+ * Channel partition for this turn's history. Each surface keeps its own
66
+ * conversation context: TUI/stdin is `'tui:stdin'`, Telegram DM is
67
+ * `agent:<name>:telegram:dm:<chatId>`, A2A drains use `a2a:<peer>`,
68
+ * marketplace uses `'marketplace'`. Missing key falls back to `'default'`.
69
+ *
70
+ * Backward-compatible: omitting the key keeps the legacy single-history
71
+ * behavior under the `'default'` channel.
72
+ */
73
+ channelKey?: string
74
+ /**
75
+ * Cancel the in-flight turn. Aborts the upstream HTTP fetch (so Mantle
76
+ * Compute stops billing the round-trip immediately) and short-circuits
77
+ * the tool-call loop. The promise rejects with a DOMException whose
78
+ * `.name === 'AbortError'`. Caller should catch that and treat it as
79
+ * a clean operator-driven cancel, not an error.
80
+ */
81
+ signal?: AbortSignal
82
+ /**
83
+ * Per-turn observer of tool-call lifecycle. Fired by the brain before and
84
+ * after each tool execution. Use for UI streaming (TG progress message,
85
+ * TUI bridge) without bothering the brain-construction onToolCall (which
86
+ * is the actual tool executor). Errors swallowed by the brain.
87
+ */
88
+ onToolEvent?: (ev: BrainToolEvent) => void
89
+ /**
90
+ * Per-turn observer of compaction events. Fires when the pre-flight
91
+ * threshold check triggers a summarize-fold of older messages. TUI
92
+ * surfaces this as a system row; TG dispatchers leave it silent.
93
+ */
94
+ onCompactionEvent?: (ev: BrainCompactionEvent) => void
95
+ }
96
+
97
+ export interface BrainTurn {
98
+ content: string | null
99
+ toolCalls: ToolCall[]
100
+ reasoningContent?: string
101
+ finishReason?: string
102
+ usage?: {
103
+ promptTokens?: number
104
+ completionTokens?: number
105
+ totalTokens?: number
106
+ cachedTokens?: number
107
+ }
108
+ }
109
+
110
+ export interface Brain {
111
+ infer(input: BrainInferInput): Promise<BrainTurn>
112
+ /**
113
+ * v0.20.0: clear a channel's history. Optional so legacy non-OG brains
114
+ * (StubBrain etc) don't have to implement it.
115
+ */
116
+ clearChannel?(channelKey?: string): Promise<void> | void
117
+ }
118
+
119
+ export interface BrainProvider {
120
+ name: string
121
+ build(opts: BrainProviderOpts): Promise<Brain>
122
+ }
123
+
124
+ export interface BrainProviderOpts {
125
+ systemPrompt: string
126
+ tools: ToolSchema[]
127
+ maxTokens?: number
128
+ maxOutputTokens?: number
129
+ }