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.
- package/README.md +24 -0
- package/package.json +65 -0
- package/src/brain/compaction.test.ts +156 -0
- package/src/brain/compaction.ts +131 -0
- package/src/brain/demo-endpoint.ts +13 -0
- package/src/brain/frozen-prefix.test.ts +235 -0
- package/src/brain/frozen-prefix.ts +320 -0
- package/src/brain/history-persist.test.ts +129 -0
- package/src/brain/history-persist.ts +154 -0
- package/src/brain/index.ts +44 -0
- package/src/brain/openai-brain.test.ts +61 -0
- package/src/brain/openai-brain.ts +544 -0
- package/src/brain/sanitize.test.ts +27 -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 +35 -0
- package/src/claude-plugins/discovery.test.ts +71 -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.test.ts +186 -0
- package/src/commands/registry.ts +255 -0
- package/src/config.ts +201 -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.test.ts +43 -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 +13 -0
- package/src/index.test.ts +6 -0
- package/src/index.ts +314 -0
- package/src/locks.test.ts +259 -0
- package/src/locks.ts +233 -0
- package/src/mcp/discovery.test.ts +97 -0
- package/src/mcp/discovery.ts +150 -0
- package/src/mcp/index.ts +10 -0
- package/src/mcp/manager.test.ts +97 -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.test.ts +41 -0
- package/src/memory/edit.ts +53 -0
- package/src/memory/encryption.test.ts +68 -0
- package/src/memory/encryption.ts +94 -0
- package/src/memory/fs-util.ts +15 -0
- package/src/memory/index-file.ts +74 -0
- package/src/memory/index-sync.test.ts +81 -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.test.ts +90 -0
- package/src/memory/pack-blob.ts +120 -0
- package/src/memory/pack-gather.test.ts +110 -0
- package/src/memory/pack-gather.ts +112 -0
- package/src/memory/parser.test.ts +29 -0
- package/src/memory/parser.ts +20 -0
- package/src/memory/path-drift.test.ts +71 -0
- package/src/memory/read-tool-fallback.test.ts +54 -0
- package/src/memory/read-tool.ts +198 -0
- package/src/memory/save-tool.test.ts +193 -0
- package/src/memory/save-tool.ts +189 -0
- package/src/memory/scan.test.ts +24 -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.test.ts +106 -0
- package/src/migration/option3-crypto.ts +143 -0
- package/src/pairing.test.ts +212 -0
- package/src/pairing.ts +285 -0
- package/src/paths.ts +70 -0
- package/src/permission/dangerous.ts +105 -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/permission.test.ts +299 -0
- package/src/permission/service.ts +191 -0
- package/src/plugins/context.ts +226 -0
- package/src/plugins/hooks.ts +81 -0
- package/src/plugins/index.ts +24 -0
- package/src/plugins/plugins.test.ts +196 -0
- package/src/plugins/tool-search.ts +49 -0
- package/src/public/card.test.ts +70 -0
- package/src/public/card.ts +67 -0
- package/src/runtime/activity.ts +29 -0
- package/src/runtime/index.ts +7 -0
- package/src/runtime/runtime.test.ts +55 -0
- package/src/runtime/runtime.ts +126 -0
- package/src/sandbox/credentials.ts +25 -0
- package/src/sandbox/docker.ts +389 -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/sandbox.test.ts +386 -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.test.ts +137 -0
- package/src/skills/scanner.ts +257 -0
- package/src/skills/triggers.test.ts +77 -0
- package/src/skills/triggers.ts +78 -0
- package/src/skills/types.ts +37 -0
- package/src/storage/encryption.test.ts +30 -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.test.ts +29 -0
- package/src/storage/sqlite.ts +95 -0
- package/src/storage/types.ts +21 -0
- package/src/tools/escalation.test.ts +348 -0
- package/src/tools/escalation.ts +200 -0
- package/src/tools/index.ts +11 -0
- package/src/tools/registry.test.ts +70 -0
- package/src/tools/registry.ts +152 -0
- package/src/tools/types.ts +65 -0
- package/src/tools/zod-helpers.test.ts +63 -0
- package/src/tools/zod-helpers.ts +36 -0
- package/src/tools/zod-schema.ts +99 -0
- package/src/wallet/drain.test.ts +41 -0
- package/src/wallet/drain.ts +76 -0
- package/src/wallet/eoa.ts +61 -0
- package/src/wallet/index.ts +14 -0
- package/src/wallet/keystore.test.ts +17 -0
- 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
|
+
}
|