lyra-core 0.1.2 → 0.1.10
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/package.json +3 -15
- package/src/brain/compaction.test.ts +0 -156
- package/src/brain/frozen-prefix.test.ts +0 -235
- package/src/brain/history-persist.test.ts +0 -129
- package/src/brain/openai-brain.test.ts +0 -61
- package/src/brain/sanitize.test.ts +0 -27
- package/src/claude-plugins/discovery.test.ts +0 -71
- package/src/commands/registry.test.ts +0 -186
- package/src/events/queue.test.ts +0 -43
- package/src/index.test.ts +0 -6
- package/src/locks.test.ts +0 -259
- package/src/mcp/discovery.test.ts +0 -97
- package/src/mcp/manager.test.ts +0 -97
- package/src/memory/edit.test.ts +0 -41
- package/src/memory/encryption.test.ts +0 -68
- package/src/memory/index-sync.test.ts +0 -81
- package/src/memory/pack-blob.test.ts +0 -90
- package/src/memory/pack-gather.test.ts +0 -110
- package/src/memory/parser.test.ts +0 -29
- package/src/memory/path-drift.test.ts +0 -71
- package/src/memory/read-tool-fallback.test.ts +0 -54
- package/src/memory/save-tool.test.ts +0 -193
- package/src/memory/scan.test.ts +0 -24
- package/src/migration/option3-crypto.test.ts +0 -106
- package/src/pairing.test.ts +0 -212
- package/src/permission/permission.test.ts +0 -299
- package/src/plugins/plugins.test.ts +0 -196
- package/src/public/card.test.ts +0 -70
- package/src/runtime/runtime.test.ts +0 -55
- package/src/sandbox/sandbox.test.ts +0 -386
- package/src/skills/scanner.test.ts +0 -137
- package/src/skills/triggers.test.ts +0 -77
- package/src/storage/encryption.test.ts +0 -30
- package/src/storage/sqlite.test.ts +0 -29
- package/src/tools/escalation.test.ts +0 -348
- package/src/tools/registry.test.ts +0 -70
- package/src/tools/zod-helpers.test.ts +0 -63
- package/src/wallet/drain.test.ts +0 -41
- package/src/wallet/keystore.test.ts +0 -17
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lyra-core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The SDK for lyra, a Sui-native policy-aware AI treasury assistant: brain, local memory + storage, the policy/approval engine, and the Sui agent keypair + chain helpers",
|
|
6
6
|
"license": "MIT",
|
|
@@ -13,26 +13,14 @@
|
|
|
13
13
|
"bugs": {
|
|
14
14
|
"url": "https://github.com/rifkyeasy/lyra/issues"
|
|
15
15
|
},
|
|
16
|
-
"keywords": [
|
|
17
|
-
"lyra",
|
|
18
|
-
"ai",
|
|
19
|
-
"agent",
|
|
20
|
-
"sui",
|
|
21
|
-
"treasury",
|
|
22
|
-
"defi",
|
|
23
|
-
"policy"
|
|
24
|
-
],
|
|
16
|
+
"keywords": ["lyra", "ai", "agent", "sui", "treasury", "defi", "policy"],
|
|
25
17
|
"publishConfig": {
|
|
26
18
|
"access": "public"
|
|
27
19
|
},
|
|
28
20
|
"engines": {
|
|
29
21
|
"bun": ">=1.1"
|
|
30
22
|
},
|
|
31
|
-
"files": [
|
|
32
|
-
"src",
|
|
33
|
-
"!src/**/*.test.ts",
|
|
34
|
-
"README.md"
|
|
35
|
-
],
|
|
23
|
+
"files": ["src", "!src/**/*.test.ts", "README.md"],
|
|
36
24
|
"main": "./src/index.ts",
|
|
37
25
|
"types": "./src/index.ts",
|
|
38
26
|
"exports": {
|
|
@@ -1,156 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'bun:test'
|
|
2
|
-
import {
|
|
3
|
-
DEFAULT_COMPACTION_OPTS,
|
|
4
|
-
SUMMARY_SYSTEM_PROMPT,
|
|
5
|
-
compactHistory,
|
|
6
|
-
estimateTokens,
|
|
7
|
-
shouldCompact,
|
|
8
|
-
} from './compaction'
|
|
9
|
-
import type { BrainMessage } from './types'
|
|
10
|
-
|
|
11
|
-
const u = (content: string): BrainMessage => ({ role: 'user', content })
|
|
12
|
-
const a = (content: string): BrainMessage => ({ role: 'assistant', content })
|
|
13
|
-
|
|
14
|
-
describe('estimateTokens', () => {
|
|
15
|
-
it('returns 0 for empty', () => {
|
|
16
|
-
expect(estimateTokens([])).toBe(0)
|
|
17
|
-
})
|
|
18
|
-
|
|
19
|
-
it('rounds up content length / 3.5', () => {
|
|
20
|
-
// 7 chars / 3.5 = 2 tokens
|
|
21
|
-
expect(estimateTokens([u('1234567')])).toBe(2)
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
it('sums across messages', () => {
|
|
25
|
-
expect(estimateTokens([u('hello'), a('world!')])).toBeGreaterThan(0)
|
|
26
|
-
expect(estimateTokens([u('hello'), a('world!')])).toBe(Math.ceil(5 / 3.5) + Math.ceil(6 / 3.5))
|
|
27
|
-
})
|
|
28
|
-
|
|
29
|
-
it('counts tool_calls overhead', () => {
|
|
30
|
-
const withTools: BrainMessage = {
|
|
31
|
-
role: 'assistant',
|
|
32
|
-
content: '',
|
|
33
|
-
toolCalls: [{ id: 'x', name: 'shell.run', args: { command: 'ls' } }],
|
|
34
|
-
}
|
|
35
|
-
expect(estimateTokens([withTools])).toBeGreaterThan(0)
|
|
36
|
-
})
|
|
37
|
-
})
|
|
38
|
-
|
|
39
|
-
describe('shouldCompact', () => {
|
|
40
|
-
// Trigger tokens = threshold * contextWindow = 0.5 * 1000 = 500.
|
|
41
|
-
// Min history length to consider = keepRecent * 2 + 4 = 8 messages.
|
|
42
|
-
const opts = { threshold: 0.5, contextWindow: 1000, keepRecent: 2 }
|
|
43
|
-
|
|
44
|
-
it('returns null when history too short to compact', () => {
|
|
45
|
-
expect(shouldCompact([], null, opts)).toBeNull()
|
|
46
|
-
expect(shouldCompact([u('a'), a('b')], null, opts)).toBeNull()
|
|
47
|
-
})
|
|
48
|
-
|
|
49
|
-
it('returns null when token count below threshold', () => {
|
|
50
|
-
const tiny: BrainMessage[] = []
|
|
51
|
-
for (let i = 0; i < 20; i++) tiny.push(u('x'))
|
|
52
|
-
// 20 single-char messages → ~20 tokens, threshold = 500
|
|
53
|
-
expect(shouldCompact(tiny, null, opts)).toBeNull()
|
|
54
|
-
})
|
|
55
|
-
|
|
56
|
-
it('returns trigger tokens when estimate exceeds threshold', () => {
|
|
57
|
-
const big: BrainMessage[] = []
|
|
58
|
-
for (let i = 0; i < 20; i++) big.push(u('x'.repeat(100)))
|
|
59
|
-
// 20 × ~29 tokens = ~580 tokens; threshold = 500
|
|
60
|
-
const r = shouldCompact(big, null, opts)
|
|
61
|
-
expect(r).not.toBeNull()
|
|
62
|
-
expect(r!).toBeGreaterThan(500)
|
|
63
|
-
})
|
|
64
|
-
|
|
65
|
-
it('uses lastTurnPromptTokens when larger than estimate', () => {
|
|
66
|
-
const small: BrainMessage[] = []
|
|
67
|
-
for (let i = 0; i < 20; i++) small.push(u('x'))
|
|
68
|
-
const r = shouldCompact(small, 999, opts)
|
|
69
|
-
expect(r).toBe(999)
|
|
70
|
-
})
|
|
71
|
-
|
|
72
|
-
it('uses estimate when larger than lastTurnPromptTokens', () => {
|
|
73
|
-
const big: BrainMessage[] = []
|
|
74
|
-
for (let i = 0; i < 20; i++) big.push(u('x'.repeat(100)))
|
|
75
|
-
const est = estimateTokens(big)
|
|
76
|
-
const r = shouldCompact(big, 50, opts)
|
|
77
|
-
expect(r).toBe(est)
|
|
78
|
-
})
|
|
79
|
-
|
|
80
|
-
it('respects keepRecent cutoff', () => {
|
|
81
|
-
// keepRecent=100 → min 204 messages required
|
|
82
|
-
const opts2 = { threshold: 0.001, contextWindow: 100, keepRecent: 100 }
|
|
83
|
-
const ten: BrainMessage[] = []
|
|
84
|
-
for (let i = 0; i < 50; i++) ten.push(u('x'.repeat(1000)))
|
|
85
|
-
expect(shouldCompact(ten, null, opts2)).toBeNull()
|
|
86
|
-
})
|
|
87
|
-
})
|
|
88
|
-
|
|
89
|
-
describe('compactHistory', () => {
|
|
90
|
-
it('returns unchanged when history shorter than keepRecent*2', async () => {
|
|
91
|
-
const opts = { ...DEFAULT_COMPACTION_OPTS, keepRecent: 4 }
|
|
92
|
-
const h = [u('1'), a('2'), u('3'), a('4')]
|
|
93
|
-
const result = await compactHistory(h, opts, async () => 'summary')
|
|
94
|
-
expect(result).toEqual(h)
|
|
95
|
-
})
|
|
96
|
-
|
|
97
|
-
it('folds older into a summary message', async () => {
|
|
98
|
-
const opts = { ...DEFAULT_COMPACTION_OPTS, keepRecent: 2 }
|
|
99
|
-
const h: BrainMessage[] = []
|
|
100
|
-
for (let i = 0; i < 10; i++) h.push(u(`msg ${i}`))
|
|
101
|
-
const result = await compactHistory(h, opts, async older => {
|
|
102
|
-
expect(older.length).toBe(6) // 10 - keepRecent*2
|
|
103
|
-
return 'OLDER SUMMARY'
|
|
104
|
-
})
|
|
105
|
-
// Expected: [summary, ...last 4 messages]
|
|
106
|
-
expect(result.length).toBe(5)
|
|
107
|
-
expect(result[0]?.role).toBe('user')
|
|
108
|
-
expect(result[0]?.content).toContain('<previous-context-summary>')
|
|
109
|
-
expect(result[0]?.content).toContain('OLDER SUMMARY')
|
|
110
|
-
expect(result[1]?.content).toBe('msg 6')
|
|
111
|
-
expect(result[4]?.content).toBe('msg 9')
|
|
112
|
-
})
|
|
113
|
-
|
|
114
|
-
it('wraps summary in tag', async () => {
|
|
115
|
-
const opts = { ...DEFAULT_COMPACTION_OPTS, keepRecent: 2 }
|
|
116
|
-
const h: BrainMessage[] = []
|
|
117
|
-
for (let i = 0; i < 10; i++) h.push(u(`msg ${i}`))
|
|
118
|
-
const result = await compactHistory(h, opts, async () => 'X')
|
|
119
|
-
expect(result[0]?.content).toMatch(
|
|
120
|
-
/^<previous-context-summary>\nX\n<\/previous-context-summary>$/,
|
|
121
|
-
)
|
|
122
|
-
})
|
|
123
|
-
|
|
124
|
-
it('propagates summarize errors', async () => {
|
|
125
|
-
const opts = { ...DEFAULT_COMPACTION_OPTS, keepRecent: 1 }
|
|
126
|
-
const h: BrainMessage[] = []
|
|
127
|
-
for (let i = 0; i < 10; i++) h.push(u(`msg ${i}`))
|
|
128
|
-
await expect(
|
|
129
|
-
compactHistory(h, opts, async () => {
|
|
130
|
-
throw new Error('summarize fail')
|
|
131
|
-
}),
|
|
132
|
-
).rejects.toThrow('summarize fail')
|
|
133
|
-
})
|
|
134
|
-
})
|
|
135
|
-
|
|
136
|
-
describe('SUMMARY_SYSTEM_PROMPT', () => {
|
|
137
|
-
it('mentions key elements to preserve', () => {
|
|
138
|
-
const lower = SUMMARY_SYSTEM_PROMPT.toLowerCase()
|
|
139
|
-
expect(lower).toContain('facts')
|
|
140
|
-
expect(lower).toContain('decisions')
|
|
141
|
-
expect(lower).toContain('tool outputs')
|
|
142
|
-
})
|
|
143
|
-
|
|
144
|
-
it('forbids preamble', () => {
|
|
145
|
-
expect(SUMMARY_SYSTEM_PROMPT).toMatch(/preamble/i)
|
|
146
|
-
})
|
|
147
|
-
})
|
|
148
|
-
|
|
149
|
-
describe('DEFAULT_COMPACTION_OPTS', () => {
|
|
150
|
-
it('targets Qwen 1M with conservative threshold', () => {
|
|
151
|
-
expect(DEFAULT_COMPACTION_OPTS.contextWindow).toBe(1_000_000)
|
|
152
|
-
expect(DEFAULT_COMPACTION_OPTS.threshold).toBeGreaterThan(0)
|
|
153
|
-
expect(DEFAULT_COMPACTION_OPTS.threshold).toBeLessThan(1)
|
|
154
|
-
expect(DEFAULT_COMPACTION_OPTS.keepRecent).toBeGreaterThan(0)
|
|
155
|
-
})
|
|
156
|
-
})
|
|
@@ -1,235 +0,0 @@
|
|
|
1
|
-
import { expect, test } from 'bun:test'
|
|
2
|
-
import { buildFrozenPrefix, renderFrozenPrefix, renderUserContext } from './frozen-prefix'
|
|
3
|
-
|
|
4
|
-
test('buildFrozenPrefix without memory index returns system prompt + session', () => {
|
|
5
|
-
const p = buildFrozenPrefix({ memoryIndex: null, timestamp: null })
|
|
6
|
-
expect(p.memoryIndexText).toBeNull()
|
|
7
|
-
expect(p.systemPrompt.length).toBeGreaterThan(0)
|
|
8
|
-
expect(renderFrozenPrefix(p)).toBe(`${p.systemPrompt}\n`)
|
|
9
|
-
})
|
|
10
|
-
|
|
11
|
-
test('buildFrozenPrefix with memory index puts it in renderUserContext, NOT system prompt', () => {
|
|
12
|
-
const memoryIndex = {
|
|
13
|
-
lines: ['# agent-id — Memory Index', '', '## Memories', '', '- [foo](agent/foo.md) — hello'],
|
|
14
|
-
entries: new Map(),
|
|
15
|
-
}
|
|
16
|
-
const p = buildFrozenPrefix({ memoryIndex, timestamp: null })
|
|
17
|
-
const sys = renderFrozenPrefix(p)
|
|
18
|
-
expect(sys).not.toContain('MEMORY.md (index)')
|
|
19
|
-
const ctx = renderUserContext(p)
|
|
20
|
-
expect(ctx).not.toBeNull()
|
|
21
|
-
expect(ctx!).toContain('MEMORY.md (index)')
|
|
22
|
-
expect(ctx!).toContain('foo')
|
|
23
|
-
expect(ctx!).toContain('<system-reminder>')
|
|
24
|
-
})
|
|
25
|
-
|
|
26
|
-
test('buildFrozenPrefix accepts custom system prompt', () => {
|
|
27
|
-
const p = buildFrozenPrefix({ systemPrompt: 'custom.', memoryIndex: null, timestamp: null })
|
|
28
|
-
expect(renderFrozenPrefix(p)).toBe('custom.\n')
|
|
29
|
-
})
|
|
30
|
-
|
|
31
|
-
test('buildFrozenPrefix loads identity + persona content into prefix', () => {
|
|
32
|
-
const p = buildFrozenPrefix({
|
|
33
|
-
memoryIndex: null,
|
|
34
|
-
identity: '# I am agent #42',
|
|
35
|
-
persona: '# I am terse',
|
|
36
|
-
timestamp: null,
|
|
37
|
-
})
|
|
38
|
-
const rendered = renderFrozenPrefix(p)
|
|
39
|
-
expect(rendered).toContain('Identity (canonical')
|
|
40
|
-
expect(rendered).toContain('I am agent #42')
|
|
41
|
-
expect(rendered).toContain('Persona (voice')
|
|
42
|
-
expect(rendered).toContain('I am terse')
|
|
43
|
-
})
|
|
44
|
-
|
|
45
|
-
test('buildFrozenPrefix appends per-tool guidance for loaded tools only', () => {
|
|
46
|
-
const p = buildFrozenPrefix({
|
|
47
|
-
memoryIndex: null,
|
|
48
|
-
loadedToolNames: ['memory.save', 'memory.read'],
|
|
49
|
-
timestamp: null,
|
|
50
|
-
})
|
|
51
|
-
const rendered = renderFrozenPrefix(p)
|
|
52
|
-
expect(rendered).toContain('Save durable facts using `memory.save` proactively')
|
|
53
|
-
expect(rendered).toContain('call `memory.read`')
|
|
54
|
-
// No guidance for unknown tools
|
|
55
|
-
const p2 = buildFrozenPrefix({
|
|
56
|
-
memoryIndex: null,
|
|
57
|
-
loadedToolNames: ['unknown.tool'],
|
|
58
|
-
timestamp: null,
|
|
59
|
-
})
|
|
60
|
-
expect(p2.toolGuidance).toEqual([])
|
|
61
|
-
})
|
|
62
|
-
|
|
63
|
-
test('buildFrozenPrefix includes session timestamp by default', () => {
|
|
64
|
-
const p = buildFrozenPrefix({ memoryIndex: null })
|
|
65
|
-
expect(p.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/)
|
|
66
|
-
expect(renderFrozenPrefix(p)).toContain('Session started:')
|
|
67
|
-
})
|
|
68
|
-
|
|
69
|
-
test('renderUserContext returns null when nothing to inject', () => {
|
|
70
|
-
const p = buildFrozenPrefix({ memoryIndex: null, timestamp: null })
|
|
71
|
-
expect(renderUserContext(p)).toBeNull()
|
|
72
|
-
})
|
|
73
|
-
|
|
74
|
-
test('buildFrozenPrefix filters claude-code agent-browser skill out of the index', () => {
|
|
75
|
-
const skills = [
|
|
76
|
-
{
|
|
77
|
-
id: 'claude-code:agent-browser',
|
|
78
|
-
name: 'agent-browser',
|
|
79
|
-
description: 'Automates browser interactions',
|
|
80
|
-
path: '/x/SKILL.md',
|
|
81
|
-
source: 'claude-code' as const,
|
|
82
|
-
frontmatter: { name: 'agent-browser', description: 'Automates browser interactions' },
|
|
83
|
-
},
|
|
84
|
-
{
|
|
85
|
-
id: 'claude-code:hakr',
|
|
86
|
-
name: 'hakr',
|
|
87
|
-
description: 'Hacker News CLI',
|
|
88
|
-
path: '/y/SKILL.md',
|
|
89
|
-
source: 'claude-code' as const,
|
|
90
|
-
frontmatter: { name: 'hakr', description: 'Hacker News CLI' },
|
|
91
|
-
},
|
|
92
|
-
]
|
|
93
|
-
const p = buildFrozenPrefix({ memoryIndex: null, timestamp: null, skills })
|
|
94
|
-
expect(p.skillIndexText).toContain('hakr')
|
|
95
|
-
expect(p.skillIndexText).not.toContain('agent-browser')
|
|
96
|
-
expect(p.skillIndexText).not.toContain('claude-code:agent-browser')
|
|
97
|
-
})
|
|
98
|
-
|
|
99
|
-
test('default system prompt includes browser guidance always-on (not conditional)', () => {
|
|
100
|
-
const p = buildFrozenPrefix({ memoryIndex: null, timestamp: null })
|
|
101
|
-
const rendered = renderFrozenPrefix(p)
|
|
102
|
-
expect(rendered).toContain('browser.navigate')
|
|
103
|
-
expect(rendered).toContain('headless Chromium')
|
|
104
|
-
expect(rendered).toContain('agent-browser')
|
|
105
|
-
})
|
|
106
|
-
|
|
107
|
-
test('default system prompt forbids pre-flight environment probes for browser', () => {
|
|
108
|
-
// v0.19.18 regression guard: brain was hitting `shell.run "which chromium ..."`
|
|
109
|
-
// before browser.navigate, blocking forever on the resulting approval and
|
|
110
|
-
// then hallucinating "browser tools aren't available in this sandbox" when
|
|
111
|
-
// the probe was denied. The guidance must explicitly forbid those probes.
|
|
112
|
-
const p = buildFrozenPrefix({ memoryIndex: null, timestamp: null })
|
|
113
|
-
const rendered = renderFrozenPrefix(p)
|
|
114
|
-
expect(rendered).toContain('Do NOT pre-probe the environment')
|
|
115
|
-
expect(rendered).toContain('which chromium')
|
|
116
|
-
expect(rendered).toContain('self-contained')
|
|
117
|
-
})
|
|
118
|
-
|
|
119
|
-
test('default system prompt includes tool-use enforcement', () => {
|
|
120
|
-
const p = buildFrozenPrefix({ memoryIndex: null, timestamp: null })
|
|
121
|
-
const rendered = renderFrozenPrefix(p)
|
|
122
|
-
expect(rendered).toContain('You MUST use your tools')
|
|
123
|
-
expect(rendered).toContain('NEVER answer these from memory')
|
|
124
|
-
})
|
|
125
|
-
|
|
126
|
-
test('buildFrozenPrefix appends operator promptAppend under # Operator instructions', () => {
|
|
127
|
-
const p = buildFrozenPrefix({
|
|
128
|
-
memoryIndex: null,
|
|
129
|
-
timestamp: null,
|
|
130
|
-
promptAppend: 'Always reply in Indonesian.',
|
|
131
|
-
})
|
|
132
|
-
expect(p.appendText).toBe('Always reply in Indonesian.')
|
|
133
|
-
const rendered = renderFrozenPrefix(p)
|
|
134
|
-
expect(rendered).toContain('# Operator instructions')
|
|
135
|
-
expect(rendered).toContain('Always reply in Indonesian.')
|
|
136
|
-
})
|
|
137
|
-
|
|
138
|
-
test('buildFrozenPrefix renders envInfo cwd + platform', () => {
|
|
139
|
-
const p = buildFrozenPrefix({
|
|
140
|
-
memoryIndex: null,
|
|
141
|
-
timestamp: null,
|
|
142
|
-
envInfo: { cwd: '/tmp/x', platform: 'darwin' },
|
|
143
|
-
})
|
|
144
|
-
const rendered = renderFrozenPrefix(p)
|
|
145
|
-
expect(rendered).toContain('# Environment')
|
|
146
|
-
expect(rendered).toContain('cwd: /tmp/x')
|
|
147
|
-
expect(rendered).toContain('platform: darwin')
|
|
148
|
-
})
|
|
149
|
-
|
|
150
|
-
test('envInfo with sandbox=docker surfaces inner OS + workspace mount + scope', () => {
|
|
151
|
-
const p = buildFrozenPrefix({
|
|
152
|
-
memoryIndex: null,
|
|
153
|
-
timestamp: null,
|
|
154
|
-
envInfo: {
|
|
155
|
-
cwd: '/tmp/x',
|
|
156
|
-
platform: 'darwin',
|
|
157
|
-
sandbox: {
|
|
158
|
-
mode: 'docker',
|
|
159
|
-
label: 'podman:nikolaik/python-nodejs:python3.11-nodejs20+workspace',
|
|
160
|
-
innerOs: 'linux',
|
|
161
|
-
workspaceMount: '/workspace',
|
|
162
|
-
scope: 'shell.run, code.execute, shell.process_start run inside the container',
|
|
163
|
-
},
|
|
164
|
-
},
|
|
165
|
-
})
|
|
166
|
-
const rendered = renderFrozenPrefix(p)
|
|
167
|
-
expect(rendered).toContain('sandbox: docker (podman:')
|
|
168
|
-
expect(rendered).toContain('inner os: linux')
|
|
169
|
-
expect(rendered).toContain('workspace mount: host cwd is bind-mounted at /workspace')
|
|
170
|
-
expect(rendered).toContain('scope: shell.run, code.execute')
|
|
171
|
-
})
|
|
172
|
-
|
|
173
|
-
test('envInfo with sandbox.mode=none does NOT add the sandbox section', () => {
|
|
174
|
-
const p = buildFrozenPrefix({
|
|
175
|
-
memoryIndex: null,
|
|
176
|
-
timestamp: null,
|
|
177
|
-
envInfo: {
|
|
178
|
-
cwd: '/tmp/x',
|
|
179
|
-
platform: 'darwin',
|
|
180
|
-
sandbox: { mode: 'none', label: 'none' },
|
|
181
|
-
},
|
|
182
|
-
})
|
|
183
|
-
const rendered = renderFrozenPrefix(p)
|
|
184
|
-
expect(rendered).toContain('cwd: /tmp/x')
|
|
185
|
-
expect(rendered).not.toContain('sandbox:')
|
|
186
|
-
})
|
|
187
|
-
|
|
188
|
-
test('envInfo with sandbox=os surfaces label + scope under # Environment', () => {
|
|
189
|
-
const p = buildFrozenPrefix({
|
|
190
|
-
memoryIndex: null,
|
|
191
|
-
timestamp: null,
|
|
192
|
-
envInfo: {
|
|
193
|
-
cwd: '/tmp/x',
|
|
194
|
-
platform: 'darwin',
|
|
195
|
-
sandbox: {
|
|
196
|
-
mode: 'os',
|
|
197
|
-
label: 'os:darwin',
|
|
198
|
-
innerOs: 'darwin',
|
|
199
|
-
workspaceMount: null,
|
|
200
|
-
scope: 'spawns wrapped in sandbox-exec; writes outside agentDir + cwd denied',
|
|
201
|
-
},
|
|
202
|
-
},
|
|
203
|
-
})
|
|
204
|
-
const rendered = renderFrozenPrefix(p)
|
|
205
|
-
expect(rendered).toContain('sandbox: os (os:darwin)')
|
|
206
|
-
expect(rendered).toContain('scope: spawns wrapped in sandbox-exec')
|
|
207
|
-
expect(rendered).not.toContain('workspace mount:')
|
|
208
|
-
})
|
|
209
|
-
|
|
210
|
-
test('skill-shadow filter keeps lyra-source skills with the same name', () => {
|
|
211
|
-
const skills = [
|
|
212
|
-
{
|
|
213
|
-
id: 'lyra:browser',
|
|
214
|
-
name: 'browser',
|
|
215
|
-
description: 'Lyra native browser playbook',
|
|
216
|
-
path: '/z/SKILL.md',
|
|
217
|
-
source: 'lyra' as const,
|
|
218
|
-
frontmatter: { name: 'browser', description: 'Lyra native browser playbook' },
|
|
219
|
-
},
|
|
220
|
-
]
|
|
221
|
-
const p = buildFrozenPrefix({ memoryIndex: null, timestamp: null, skills })
|
|
222
|
-
expect(p.skillIndexText).toContain('lyra:browser')
|
|
223
|
-
})
|
|
224
|
-
|
|
225
|
-
// v0.22.0: brain emitted em-dashes in prose + table separators, violating the
|
|
226
|
-
// project hard rule (global CLAUDE.md). The system prompt now carries an
|
|
227
|
-
// explicit ASCII-hyphen rule in the Tone and style section.
|
|
228
|
-
test('system prompt instructs brain to use ASCII hyphens, not em-dashes', () => {
|
|
229
|
-
const p = buildFrozenPrefix({ memoryIndex: null, timestamp: null })
|
|
230
|
-
const rendered = renderFrozenPrefix(p)
|
|
231
|
-
expect(rendered).toMatch(/ASCII hyphens/)
|
|
232
|
-
expect(rendered).toMatch(/em-dashes/)
|
|
233
|
-
// Reference U+2014 explicitly so the brain knows the exact codepoint
|
|
234
|
-
expect(rendered).toMatch(/U\+2014/)
|
|
235
|
-
})
|
|
@@ -1,129 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'bun:test'
|
|
2
|
-
import { mkdtempSync, readFileSync } from 'node:fs'
|
|
3
|
-
import { tmpdir } from 'node:os'
|
|
4
|
-
import { join } from 'node:path'
|
|
5
|
-
import { createFsHistoryPersist, sanitizeChannelKey } from './history-persist'
|
|
6
|
-
import type { BrainMessage } from './types'
|
|
7
|
-
|
|
8
|
-
const u = (content: string): BrainMessage => ({ role: 'user', content })
|
|
9
|
-
const a = (content: string): BrainMessage => ({ role: 'assistant', content })
|
|
10
|
-
|
|
11
|
-
function makeTmpDir(): string {
|
|
12
|
-
return mkdtempSync(join(tmpdir(), 'lyra-history-test-'))
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
describe('sanitizeChannelKey', () => {
|
|
16
|
-
it('passes safe keys through', () => {
|
|
17
|
-
expect(sanitizeChannelKey('default')).toBe('default')
|
|
18
|
-
expect(sanitizeChannelKey('tui:stdin')).toBe('tui_stdin')
|
|
19
|
-
expect(sanitizeChannelKey('a-b_c.d')).toBe('a-b_c.d')
|
|
20
|
-
})
|
|
21
|
-
|
|
22
|
-
it('replaces unsafe chars', () => {
|
|
23
|
-
expect(sanitizeChannelKey('agent:specter:telegram:dm:12345')).toBe(
|
|
24
|
-
'agent_specter_telegram_dm_12345',
|
|
25
|
-
)
|
|
26
|
-
expect(sanitizeChannelKey('a/b\\c')).toBe('a_b_c')
|
|
27
|
-
})
|
|
28
|
-
|
|
29
|
-
it('caps length', () => {
|
|
30
|
-
const long = 'x'.repeat(500)
|
|
31
|
-
const out = sanitizeChannelKey(long)
|
|
32
|
-
expect(out.length).toBeLessThanOrEqual(200)
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
it('falls back to default on empty/all-unsafe', () => {
|
|
36
|
-
expect(sanitizeChannelKey('')).toBe('default')
|
|
37
|
-
// 250 unsafe chars → all replaced with _, then sliced to 200, still non-empty so kept
|
|
38
|
-
expect(sanitizeChannelKey('/'.repeat(250)).length).toBe(200)
|
|
39
|
-
})
|
|
40
|
-
})
|
|
41
|
-
|
|
42
|
-
describe('createFsHistoryPersist', () => {
|
|
43
|
-
it('roundtrips a single channel', async () => {
|
|
44
|
-
const dir = makeTmpDir()
|
|
45
|
-
const persist = createFsHistoryPersist({ dir })
|
|
46
|
-
await persist.appendTurn('default', u('hi'), a('hello'))
|
|
47
|
-
const loaded = await persist.loadAll()
|
|
48
|
-
expect(loaded.get('default')).toEqual([u('hi'), a('hello')])
|
|
49
|
-
})
|
|
50
|
-
|
|
51
|
-
it('partitions per channel', async () => {
|
|
52
|
-
const dir = makeTmpDir()
|
|
53
|
-
const persist = createFsHistoryPersist({ dir })
|
|
54
|
-
await persist.appendTurn('tui:stdin', u('A'), a('B'))
|
|
55
|
-
await persist.appendTurn('agent:foo:tg:dm:1', u('C'), a('D'))
|
|
56
|
-
const loaded = await persist.loadAll()
|
|
57
|
-
expect(loaded.get('tui:stdin')).toEqual([u('A'), a('B')])
|
|
58
|
-
expect(loaded.get('agent:foo:tg:dm:1')).toEqual([u('C'), a('D')])
|
|
59
|
-
})
|
|
60
|
-
|
|
61
|
-
it('appends append onto existing file', async () => {
|
|
62
|
-
const dir = makeTmpDir()
|
|
63
|
-
const persist = createFsHistoryPersist({ dir })
|
|
64
|
-
await persist.appendTurn('default', u('1'), a('2'))
|
|
65
|
-
await persist.appendTurn('default', u('3'), a('4'))
|
|
66
|
-
const loaded = await persist.loadAll()
|
|
67
|
-
expect(loaded.get('default')).toEqual([u('1'), a('2'), u('3'), a('4')])
|
|
68
|
-
})
|
|
69
|
-
|
|
70
|
-
it('clearChannel removes the file', async () => {
|
|
71
|
-
const dir = makeTmpDir()
|
|
72
|
-
const persist = createFsHistoryPersist({ dir })
|
|
73
|
-
await persist.appendTurn('default', u('x'), a('y'))
|
|
74
|
-
await persist.clearChannel('default')
|
|
75
|
-
const loaded = await persist.loadAll()
|
|
76
|
-
expect(loaded.get('default')).toBeUndefined()
|
|
77
|
-
})
|
|
78
|
-
|
|
79
|
-
it('clearChannel is idempotent on missing file', async () => {
|
|
80
|
-
const dir = makeTmpDir()
|
|
81
|
-
const persist = createFsHistoryPersist({ dir })
|
|
82
|
-
await persist.clearChannel('never-existed')
|
|
83
|
-
// No throw
|
|
84
|
-
expect(true).toBe(true)
|
|
85
|
-
})
|
|
86
|
-
|
|
87
|
-
it('rewriteChannel atomically replaces history', async () => {
|
|
88
|
-
const dir = makeTmpDir()
|
|
89
|
-
const persist = createFsHistoryPersist({ dir })
|
|
90
|
-
await persist.appendTurn('default', u('old1'), a('old2'))
|
|
91
|
-
await persist.appendTurn('default', u('old3'), a('old4'))
|
|
92
|
-
await persist.rewriteChannel('default', [u('SUMMARY'), u('new'), a('reply')])
|
|
93
|
-
const loaded = await persist.loadAll()
|
|
94
|
-
expect(loaded.get('default')).toEqual([u('SUMMARY'), u('new'), a('reply')])
|
|
95
|
-
})
|
|
96
|
-
|
|
97
|
-
it('loadAll returns empty Map when dir does not exist', async () => {
|
|
98
|
-
const persist = createFsHistoryPersist({ dir: '/nonexistent/path/lyra-test' })
|
|
99
|
-
const loaded = await persist.loadAll()
|
|
100
|
-
expect(loaded.size).toBe(0)
|
|
101
|
-
})
|
|
102
|
-
|
|
103
|
-
it('loadAll skips malformed lines', async () => {
|
|
104
|
-
const dir = makeTmpDir()
|
|
105
|
-
const persist = createFsHistoryPersist({ dir })
|
|
106
|
-
await persist.appendTurn('default', u('good'), a('reply'))
|
|
107
|
-
// Append a bad line manually
|
|
108
|
-
const path = join(dir, 'default.jsonl')
|
|
109
|
-
const { writeFileSync } = await import('node:fs')
|
|
110
|
-
const existing = readFileSync(path, 'utf8')
|
|
111
|
-
writeFileSync(path, `${existing}not-json\n`)
|
|
112
|
-
const loaded = await persist.loadAll()
|
|
113
|
-
expect(loaded.get('default')).toEqual([u('good'), a('reply')])
|
|
114
|
-
})
|
|
115
|
-
|
|
116
|
-
it('loadAll skips records with wrong version', async () => {
|
|
117
|
-
const dir = makeTmpDir()
|
|
118
|
-
const path = join(dir, 'default.jsonl')
|
|
119
|
-
const { mkdirSync, writeFileSync } = await import('node:fs')
|
|
120
|
-
mkdirSync(dir, { recursive: true })
|
|
121
|
-
writeFileSync(
|
|
122
|
-
path,
|
|
123
|
-
`${JSON.stringify({ v: 999, channelKey: 'default', message: u('x'), ts: 1 })}\n`,
|
|
124
|
-
)
|
|
125
|
-
const persist = createFsHistoryPersist({ dir })
|
|
126
|
-
const loaded = await persist.loadAll()
|
|
127
|
-
expect(loaded.get('default')).toBeUndefined()
|
|
128
|
-
})
|
|
129
|
-
})
|
|
@@ -1,61 +0,0 @@
|
|
|
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
|
-
})
|
|
@@ -1,27 +0,0 @@
|
|
|
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
|
-
})
|