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.
- package/README.md +24 -0
- package/package.json +69 -0
- package/src/brain/compaction.ts +131 -0
- package/src/brain/frozen-prefix.ts +320 -0
- package/src/brain/history-persist.ts +154 -0
- package/src/brain/index.ts +43 -0
- package/src/brain/openai-brain.ts +533 -0
- package/src/brain/sanitize.ts +23 -0
- package/src/brain/stub.ts +20 -0
- package/src/brain/types.ts +129 -0
- package/src/chain.ts +75 -0
- package/src/claude-plugins/discovery.ts +152 -0
- package/src/claude-plugins/index.ts +6 -0
- package/src/claude-plugins/types.ts +38 -0
- package/src/commands/index.ts +16 -0
- package/src/commands/registry.ts +255 -0
- package/src/config.ts +213 -0
- package/src/economy/index.ts +6 -0
- package/src/events/index.ts +4 -0
- package/src/events/listeners.ts +37 -0
- package/src/events/queue.ts +63 -0
- package/src/events/router.ts +42 -0
- package/src/events/types.ts +28 -0
- package/src/format.ts +12 -0
- package/src/identity/agent-card.ts +110 -0
- package/src/identity/deployments.ts +20 -0
- package/src/identity/erc8004.ts +161 -0
- package/src/identity/index.ts +29 -0
- package/src/identity/keystore-blob.ts +60 -0
- package/src/identity/receipt.ts +27 -0
- package/src/identity/stub.ts +29 -0
- package/src/identity/types.ts +20 -0
- package/src/index.ts +372 -0
- package/src/locks.ts +233 -0
- package/src/mcp/discovery.ts +150 -0
- package/src/mcp/index.ts +10 -0
- package/src/mcp/manager.ts +110 -0
- package/src/mcp/stdio-client.ts +154 -0
- package/src/mcp/types.ts +44 -0
- package/src/memory/edit.ts +53 -0
- package/src/memory/encryption.ts +88 -0
- package/src/memory/fs-util.ts +15 -0
- package/src/memory/index-file.ts +74 -0
- package/src/memory/index-sync.ts +99 -0
- package/src/memory/index.ts +58 -0
- package/src/memory/list-tool.ts +105 -0
- package/src/memory/pack-blob.ts +120 -0
- package/src/memory/pack-gather.ts +112 -0
- package/src/memory/parser.ts +20 -0
- package/src/memory/read-tool.ts +198 -0
- package/src/memory/save-tool.ts +189 -0
- package/src/memory/scan.ts +63 -0
- package/src/memory/topic.ts +32 -0
- package/src/memory/types.ts +49 -0
- package/src/migration/index.ts +6 -0
- package/src/migration/option3-crypto.ts +127 -0
- package/src/operator/index.ts +9 -0
- package/src/operator/keychain.ts +53 -0
- package/src/operator/keystore-file.ts +33 -0
- package/src/operator/privkey-base.ts +60 -0
- package/src/operator/raw-privkey.ts +39 -0
- package/src/operator/signer.ts +46 -0
- package/src/operator/walletconnect.ts +454 -0
- package/src/pairing.ts +285 -0
- package/src/paths.ts +70 -0
- package/src/permission/dangerous.ts +108 -0
- package/src/permission/env-redact.ts +54 -0
- package/src/permission/index.ts +16 -0
- package/src/permission/path-guard.ts +114 -0
- package/src/permission/service.ts +191 -0
- package/src/plugins/context.ts +225 -0
- package/src/plugins/hooks.ts +81 -0
- package/src/plugins/index.ts +24 -0
- package/src/plugins/tool-search.ts +49 -0
- package/src/public/card.ts +67 -0
- package/src/runtime/activity.ts +29 -0
- package/src/runtime/index.ts +2 -0
- package/src/runtime/runtime.ts +113 -0
- package/src/sandbox/credentials.ts +25 -0
- package/src/sandbox/docker.ts +396 -0
- package/src/sandbox/factory.ts +99 -0
- package/src/sandbox/index.ts +15 -0
- package/src/sandbox/linux.ts +141 -0
- package/src/sandbox/local.ts +19 -0
- package/src/sandbox/macos.ts +71 -0
- package/src/sandbox/seatbelt-profile.ts +139 -0
- package/src/sandbox/types.ts +129 -0
- package/src/skills/index.ts +8 -0
- package/src/skills/scanner.ts +257 -0
- package/src/skills/triggers.ts +78 -0
- package/src/skills/types.ts +37 -0
- package/src/storage/encryption.ts +87 -0
- package/src/storage/factory.ts +31 -0
- package/src/storage/index.ts +11 -0
- package/src/storage/local-stub.ts +70 -0
- package/src/storage/sqlite.ts +95 -0
- package/src/storage/types.ts +21 -0
- package/src/tools/escalation.ts +200 -0
- package/src/tools/index.ts +11 -0
- package/src/tools/registry.ts +152 -0
- package/src/tools/types.ts +65 -0
- package/src/tools/zod-helpers.ts +36 -0
- package/src/tools/zod-schema.ts +99 -0
- package/src/wallet/drain.ts +79 -0
- package/src/wallet/eoa.ts +51 -0
- package/src/wallet/index.ts +47 -0
- package/src/wallet/keystore.ts +50 -0
- package/src/wallet/operator-keystore-crypto.ts +530 -0
- 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
|
+
}
|