lyra-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 (131) hide show
  1. package/README.md +24 -0
  2. package/package.json +65 -0
  3. package/src/brain/compaction.test.ts +156 -0
  4. package/src/brain/compaction.ts +131 -0
  5. package/src/brain/demo-endpoint.ts +13 -0
  6. package/src/brain/frozen-prefix.test.ts +235 -0
  7. package/src/brain/frozen-prefix.ts +320 -0
  8. package/src/brain/history-persist.test.ts +129 -0
  9. package/src/brain/history-persist.ts +154 -0
  10. package/src/brain/index.ts +44 -0
  11. package/src/brain/openai-brain.test.ts +61 -0
  12. package/src/brain/openai-brain.ts +544 -0
  13. package/src/brain/sanitize.test.ts +27 -0
  14. package/src/brain/sanitize.ts +23 -0
  15. package/src/brain/stub.ts +20 -0
  16. package/src/brain/types.ts +129 -0
  17. package/src/chain.ts +35 -0
  18. package/src/claude-plugins/discovery.test.ts +71 -0
  19. package/src/claude-plugins/discovery.ts +152 -0
  20. package/src/claude-plugins/index.ts +6 -0
  21. package/src/claude-plugins/types.ts +38 -0
  22. package/src/commands/index.ts +16 -0
  23. package/src/commands/registry.test.ts +186 -0
  24. package/src/commands/registry.ts +255 -0
  25. package/src/config.ts +201 -0
  26. package/src/economy/index.ts +6 -0
  27. package/src/events/index.ts +4 -0
  28. package/src/events/listeners.ts +37 -0
  29. package/src/events/queue.test.ts +43 -0
  30. package/src/events/queue.ts +63 -0
  31. package/src/events/router.ts +42 -0
  32. package/src/events/types.ts +28 -0
  33. package/src/format.ts +13 -0
  34. package/src/index.test.ts +6 -0
  35. package/src/index.ts +314 -0
  36. package/src/locks.test.ts +259 -0
  37. package/src/locks.ts +233 -0
  38. package/src/mcp/discovery.test.ts +97 -0
  39. package/src/mcp/discovery.ts +150 -0
  40. package/src/mcp/index.ts +10 -0
  41. package/src/mcp/manager.test.ts +97 -0
  42. package/src/mcp/manager.ts +110 -0
  43. package/src/mcp/stdio-client.ts +154 -0
  44. package/src/mcp/types.ts +44 -0
  45. package/src/memory/edit.test.ts +41 -0
  46. package/src/memory/edit.ts +53 -0
  47. package/src/memory/encryption.test.ts +68 -0
  48. package/src/memory/encryption.ts +94 -0
  49. package/src/memory/fs-util.ts +15 -0
  50. package/src/memory/index-file.ts +74 -0
  51. package/src/memory/index-sync.test.ts +81 -0
  52. package/src/memory/index-sync.ts +99 -0
  53. package/src/memory/index.ts +58 -0
  54. package/src/memory/list-tool.ts +105 -0
  55. package/src/memory/pack-blob.test.ts +90 -0
  56. package/src/memory/pack-blob.ts +120 -0
  57. package/src/memory/pack-gather.test.ts +110 -0
  58. package/src/memory/pack-gather.ts +112 -0
  59. package/src/memory/parser.test.ts +29 -0
  60. package/src/memory/parser.ts +20 -0
  61. package/src/memory/path-drift.test.ts +71 -0
  62. package/src/memory/read-tool-fallback.test.ts +54 -0
  63. package/src/memory/read-tool.ts +198 -0
  64. package/src/memory/save-tool.test.ts +193 -0
  65. package/src/memory/save-tool.ts +189 -0
  66. package/src/memory/scan.test.ts +24 -0
  67. package/src/memory/scan.ts +63 -0
  68. package/src/memory/topic.ts +32 -0
  69. package/src/memory/types.ts +49 -0
  70. package/src/migration/index.ts +6 -0
  71. package/src/migration/option3-crypto.test.ts +106 -0
  72. package/src/migration/option3-crypto.ts +143 -0
  73. package/src/pairing.test.ts +212 -0
  74. package/src/pairing.ts +285 -0
  75. package/src/paths.ts +70 -0
  76. package/src/permission/dangerous.ts +105 -0
  77. package/src/permission/env-redact.ts +54 -0
  78. package/src/permission/index.ts +16 -0
  79. package/src/permission/path-guard.ts +114 -0
  80. package/src/permission/permission.test.ts +299 -0
  81. package/src/permission/service.ts +191 -0
  82. package/src/plugins/context.ts +226 -0
  83. package/src/plugins/hooks.ts +81 -0
  84. package/src/plugins/index.ts +24 -0
  85. package/src/plugins/plugins.test.ts +196 -0
  86. package/src/plugins/tool-search.ts +49 -0
  87. package/src/public/card.test.ts +70 -0
  88. package/src/public/card.ts +67 -0
  89. package/src/runtime/activity.ts +29 -0
  90. package/src/runtime/index.ts +7 -0
  91. package/src/runtime/runtime.test.ts +55 -0
  92. package/src/runtime/runtime.ts +126 -0
  93. package/src/sandbox/credentials.ts +25 -0
  94. package/src/sandbox/docker.ts +389 -0
  95. package/src/sandbox/factory.ts +99 -0
  96. package/src/sandbox/index.ts +15 -0
  97. package/src/sandbox/linux.ts +141 -0
  98. package/src/sandbox/local.ts +19 -0
  99. package/src/sandbox/macos.ts +71 -0
  100. package/src/sandbox/sandbox.test.ts +386 -0
  101. package/src/sandbox/seatbelt-profile.ts +139 -0
  102. package/src/sandbox/types.ts +129 -0
  103. package/src/skills/index.ts +8 -0
  104. package/src/skills/scanner.test.ts +137 -0
  105. package/src/skills/scanner.ts +257 -0
  106. package/src/skills/triggers.test.ts +77 -0
  107. package/src/skills/triggers.ts +78 -0
  108. package/src/skills/types.ts +37 -0
  109. package/src/storage/encryption.test.ts +30 -0
  110. package/src/storage/encryption.ts +87 -0
  111. package/src/storage/factory.ts +31 -0
  112. package/src/storage/index.ts +11 -0
  113. package/src/storage/local-stub.ts +70 -0
  114. package/src/storage/sqlite.test.ts +29 -0
  115. package/src/storage/sqlite.ts +95 -0
  116. package/src/storage/types.ts +21 -0
  117. package/src/tools/escalation.test.ts +348 -0
  118. package/src/tools/escalation.ts +200 -0
  119. package/src/tools/index.ts +11 -0
  120. package/src/tools/registry.test.ts +70 -0
  121. package/src/tools/registry.ts +152 -0
  122. package/src/tools/types.ts +65 -0
  123. package/src/tools/zod-helpers.test.ts +63 -0
  124. package/src/tools/zod-helpers.ts +36 -0
  125. package/src/tools/zod-schema.ts +99 -0
  126. package/src/wallet/drain.test.ts +41 -0
  127. package/src/wallet/drain.ts +76 -0
  128. package/src/wallet/eoa.ts +61 -0
  129. package/src/wallet/index.ts +14 -0
  130. package/src/wallet/keystore.test.ts +17 -0
  131. package/src/wallet/keystore.ts +50 -0
@@ -0,0 +1,61 @@
1
+ import { afterEach, describe, expect, it } from 'bun:test'
2
+ import type { LyraEvent } from '../events/types'
3
+ import { buildFrozenPrefix } from './frozen-prefix'
4
+ import { OpenAIBrain } from './openai-brain'
5
+
6
+ const origFetch = globalThis.fetch
7
+
8
+ /** Replace global fetch with a canned OpenAI-compatible /chat/completions response. */
9
+ function mockFetch(body: unknown): void {
10
+ globalThis.fetch = (async () =>
11
+ new Response(JSON.stringify(body), {
12
+ status: 200,
13
+ headers: { 'content-type': 'application/json' },
14
+ })) as unknown as typeof fetch
15
+ }
16
+
17
+ function makeEvent(text: string): LyraEvent {
18
+ return { id: 'evt-1', source: 'stdin', payload: { label: 'user', data: text }, ts: 0 }
19
+ }
20
+
21
+ const prefix = buildFrozenPrefix({
22
+ systemPrompt: 'You are a test agent.',
23
+ memoryIndex: null,
24
+ identity: null,
25
+ persona: null,
26
+ loadedToolNames: [],
27
+ skills: [],
28
+ timestamp: null,
29
+ })
30
+
31
+ describe('OpenAIBrain', () => {
32
+ afterEach(() => {
33
+ globalThis.fetch = origFetch
34
+ })
35
+
36
+ it('returns assistant content from an OpenAI-compatible response', async () => {
37
+ mockFetch({
38
+ choices: [{ message: { content: 'hello from lyra' }, finish_reason: 'stop' }],
39
+ usage: { prompt_tokens: 10, completion_tokens: 3, total_tokens: 13 },
40
+ })
41
+ const brain = new OpenAIBrain({ apiKey: 'test-key', model: 'gpt-4o-mini', tools: [], prefix })
42
+ const turn = await brain.infer({ event: makeEvent('hi') })
43
+ expect(turn.content).toBe('hello from lyra')
44
+ expect(turn.toolCalls).toHaveLength(0)
45
+ expect(turn.usage?.totalTokens).toBe(13)
46
+ })
47
+
48
+ it('falls back to reasoning_content when content is empty', async () => {
49
+ mockFetch({
50
+ choices: [
51
+ {
52
+ message: { content: null, reasoning_content: '<think>x</think>the answer' },
53
+ finish_reason: 'stop',
54
+ },
55
+ ],
56
+ })
57
+ const brain = new OpenAIBrain({ apiKey: 'k', tools: [], prefix })
58
+ const turn = await brain.infer({ event: makeEvent('q') })
59
+ expect(turn.content).toBe('the answer')
60
+ })
61
+ })
@@ -0,0 +1,544 @@
1
+ /**
2
+ * Provider-agnostic, OpenAI-compatible brain for Lyra.
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
+ // OpenAI requires tool names to match ^[a-zA-Z0-9_-]+$ (no dots). lyra tools
370
+ // use dotted names (e.g. defi.yields), so sanitize on the way out and map back in.
371
+ const toSafe = (n: string) => n.replace(/[^a-zA-Z0-9_-]/g, '_')
372
+ const toOriginal = new Map<string, string>()
373
+ for (const t of this.opts.tools) {
374
+ const orig = (t as { function?: { name?: string } }).function?.name
375
+ if (orig) toOriginal.set(toSafe(orig), orig)
376
+ }
377
+ const body: Record<string, unknown> = {
378
+ model: this.model,
379
+ messages: messages.map(m => {
380
+ if (m.role === 'tool') {
381
+ return { role: 'tool', tool_call_id: m.toolCallId, content: m.content }
382
+ }
383
+ if (m.role === 'assistant' && m.toolCalls && m.toolCalls.length > 0) {
384
+ return {
385
+ role: 'assistant',
386
+ content: m.content || null,
387
+ tool_calls: m.toolCalls.map(tc => ({
388
+ id: tc.id,
389
+ type: 'function',
390
+ function: {
391
+ name: toSafe(tc.name),
392
+ arguments: typeof tc.args === 'string' ? tc.args : JSON.stringify(tc.args),
393
+ },
394
+ })),
395
+ }
396
+ }
397
+ return { role: m.role, content: m.content }
398
+ }),
399
+ max_tokens: this.opts.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS,
400
+ }
401
+ if (this.opts.tools.length > 0) {
402
+ body.tools = this.opts.tools.map(t => {
403
+ const fn = (t as { function?: { name?: string } }).function
404
+ return fn?.name ? { ...t, function: { ...fn, name: toSafe(fn.name) } } : t
405
+ })
406
+ body.tool_choice = 'auto'
407
+ }
408
+ const resp = await fetch(`${this.baseUrl}/chat/completions`, {
409
+ method: 'POST',
410
+ headers: this.headers(),
411
+ body: JSON.stringify(body),
412
+ signal,
413
+ })
414
+ if (!resp.ok) {
415
+ const text = await resp.text()
416
+ throw new Error(`Brain HTTP ${resp.status}: ${text}`)
417
+ }
418
+ const json = (await resp.json()) as {
419
+ choices: Array<{
420
+ finish_reason?: string
421
+ message: {
422
+ content?: string | null
423
+ tool_calls?: Array<{ id: string; function: { name: string; arguments: string } }>
424
+ reasoning_content?: string
425
+ }
426
+ }>
427
+ usage?: {
428
+ prompt_tokens?: number
429
+ completion_tokens?: number
430
+ total_tokens?: number
431
+ prompt_tokens_details?: { cached_tokens?: number }
432
+ }
433
+ }
434
+ const choice = json.choices[0]!
435
+ const msg = choice.message
436
+ const rawContent = msg.content
437
+ const reasoning = msg.reasoning_content
438
+ const fallbackFromReasoning =
439
+ !rawContent && reasoning && reasoning.length > 0 ? stripThinkBlocks(reasoning) : null
440
+ return {
441
+ content: rawContent ? rawContent : fallbackFromReasoning,
442
+ toolCalls: (msg.tool_calls ?? []).map(tc => ({
443
+ id: tc.id,
444
+ name: toOriginal.get(tc.function.name) ?? tc.function.name,
445
+ args: safeParseJson(tc.function.arguments),
446
+ })),
447
+ reasoningContent: msg.reasoning_content,
448
+ finishReason: choice.finish_reason,
449
+ usage: {
450
+ promptTokens: json.usage?.prompt_tokens,
451
+ completionTokens: json.usage?.completion_tokens,
452
+ totalTokens: json.usage?.total_tokens,
453
+ cachedTokens: json.usage?.prompt_tokens_details?.cached_tokens,
454
+ },
455
+ }
456
+ }
457
+ }
458
+
459
+ function normalizeUserContent(input: BrainInferInput): string {
460
+ const d = input.event.payload.data
461
+ if (typeof d === 'string') return d
462
+ return JSON.stringify(d)
463
+ }
464
+
465
+ function safeParseJson(raw: string): unknown {
466
+ try {
467
+ return JSON.parse(raw)
468
+ } catch {
469
+ return raw
470
+ }
471
+ }
472
+
473
+ export function looksLikeValidJsonString(raw: string): boolean {
474
+ if (!raw || raw.length === 0) return true
475
+ try {
476
+ JSON.parse(raw)
477
+ return true
478
+ } catch {
479
+ return false
480
+ }
481
+ }
482
+
483
+ const THINK_BLOCK_RE = /<think>[\s\S]*?<\/think>/g
484
+ const MALFORMED_TOOL_LABEL = '<malformed>'
485
+
486
+ export function stripThinkBlocks(text: string): string {
487
+ if (!text) return text
488
+ return text.replace(THINK_BLOCK_RE, '').trim()
489
+ }
490
+
491
+ export function detectBlockedToolError(content: string): string | null {
492
+ if (!content) return null
493
+ const m = content.match(/Unauthorized:\s+(\S+)\s+is a blocked tool/)
494
+ return m ? m[1]! : null
495
+ }
496
+
497
+ function findLastAssistantContent(messages: BrainMessage[]): string {
498
+ for (let i = messages.length - 1; i >= 0; i--) {
499
+ const m = messages[i]
500
+ if (m && m.role === 'assistant') return m.content
501
+ }
502
+ return ''
503
+ }
504
+
505
+ export function previewToolArgs(args: unknown): string {
506
+ if (args == null) return ''
507
+ if (typeof args === 'string') return truncatePreview(args)
508
+ if (Array.isArray(args)) return `[${args.length}]`
509
+ if (typeof args === 'object') {
510
+ const o = args as Record<string, unknown>
511
+ const keys = Object.keys(o)
512
+ if (keys.length === 0) return ''
513
+ for (const k of ['url', 'path', 'command', 'query', 'name', 'address']) {
514
+ const v = o[k]
515
+ if (typeof v === 'string' && v.length > 0) return truncatePreview(`${k}=${v}`)
516
+ }
517
+ return truncatePreview(keys.join(','))
518
+ }
519
+ try {
520
+ return truncatePreview(String(args))
521
+ } catch {
522
+ return ''
523
+ }
524
+ }
525
+
526
+ function truncatePreview(s: string): string {
527
+ const max = 60
528
+ if (s.length <= max) return s
529
+ return `${s.slice(0, max - 1)}…`
530
+ }
531
+
532
+ export function inferToolOk(content: string): boolean {
533
+ if (!content) return true
534
+ try {
535
+ const o = JSON.parse(content) as Record<string, unknown>
536
+ if (typeof o.ok === 'boolean') return o.ok
537
+ if (typeof o.error === 'string' && o.error.length > 0) return false
538
+ return true
539
+ } catch {
540
+ return !content.toLowerCase().includes('error')
541
+ }
542
+ }
543
+
544
+ export { estimateTokens }
@@ -0,0 +1,27 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { sanitizeDashes } from './sanitize'
3
+
4
+ describe('sanitizeDashes (v0.22.2 backstop)', () => {
5
+ it('replaces em-dash with comma+space (preserving surrounding spaces)', () => {
6
+ expect(sanitizeDashes('Denied — rm -rf blocked')).toBe('Denied , rm -rf blocked')
7
+ expect(sanitizeDashes('text—more')).toBe('text, more')
8
+ })
9
+ it('replaces en-dash with ASCII hyphen', () => {
10
+ expect(sanitizeDashes('range 3–5')).toBe('range 3-5')
11
+ expect(sanitizeDashes('2026–2027')).toBe('2026-2027')
12
+ })
13
+ it('passes plain ASCII text untouched', () => {
14
+ expect(sanitizeDashes('hello world')).toBe('hello world')
15
+ expect(sanitizeDashes('use rm -rf carefully')).toBe('use rm -rf carefully')
16
+ })
17
+ it('handles empty + null-ish', () => {
18
+ expect(sanitizeDashes('')).toBe('')
19
+ })
20
+ it('strips all em-dashes and en-dashes from prose-heavy input', () => {
21
+ const result = sanitizeDashes('A — B – C — D')
22
+ expect(result).not.toMatch(/[—–]/)
23
+ expect(result.startsWith('A')).toBe(true)
24
+ expect(result.endsWith('D')).toBe(true)
25
+ expect(result).toContain('B - C')
26
+ })
27
+ })
@@ -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, lyra'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 Sui
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
+ }