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
package/README.md ADDED
@@ -0,0 +1,24 @@
1
+ # lyra-core
2
+
3
+ The SDK behind **lyra**, a Sui-native, policy-aware AI treasury assistant:
4
+ the brain (OpenAI-compatible), local file-based memory + index,
5
+ the **permission service + approval floor**, plain-EOA identity + a local
6
+ encrypted keystore, the **lyra::policy (Trustless Agents) identity client**, the
7
+ plugin host, tool registry, and event queue.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ bun add lyra-core
13
+ ```
14
+
15
+ Bun / TypeScript-native (ships TS source). Requires [bun](https://bun.sh).
16
+
17
+ ## Use
18
+
19
+ Install [`lyra-ai-agent`](https://www.npmjs.com/package/lyra-ai-agent) (the
20
+ CLI) for the full agent. This package is for plugin authors and library consumers
21
+ who want to embed the runtime, the deterministic policy/approval spine, or the
22
+ lyra::policy identity client (`registerAgent`, `resolveAgentById`, `buildAgentCard`).
23
+
24
+ See the [root README](https://github.com/rifkyeasy/lyra#readme) for the full surface.
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "lyra-core",
3
+ "version": "0.1.0",
4
+ "type": "module",
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
+ "license": "MIT",
7
+ "homepage": "https://github.com/rifkyeasy/lyra",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/rifkyeasy/lyra.git",
11
+ "directory": "packages/core"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/rifkyeasy/lyra/issues"
15
+ },
16
+ "keywords": [
17
+ "lyra",
18
+ "ai",
19
+ "agent",
20
+ "sui",
21
+ "treasury",
22
+ "defi",
23
+ "policy"
24
+ ],
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "engines": {
29
+ "bun": ">=1.1"
30
+ },
31
+ "files": [
32
+ "src",
33
+ "!src/**/*.test.ts",
34
+ "README.md"
35
+ ],
36
+ "main": "./src/index.ts",
37
+ "types": "./src/index.ts",
38
+ "exports": {
39
+ ".": "./src/index.ts",
40
+ "./config": "./src/config.ts",
41
+ "./events": "./src/events/index.ts",
42
+ "./memory": "./src/memory/index.ts",
43
+ "./tools": "./src/tools/index.ts",
44
+ "./brain": "./src/brain/index.ts",
45
+ "./storage": "./src/storage/index.ts",
46
+ "./wallet": "./src/wallet/index.ts",
47
+ "./runtime": "./src/runtime/index.ts",
48
+ "./paths": "./src/paths.ts"
49
+ },
50
+ "scripts": {
51
+ "build": "tsc -b",
52
+ "test": "bun test"
53
+ },
54
+ "dependencies": {
55
+ "@mysten/sui": "^1.40.0",
56
+ "@noble/curves": "^2.2.0",
57
+ "@noble/hashes": "^2.2.0",
58
+ "gray-matter": "^4.0.3",
59
+ "qrcode-terminal": "^0.12.0",
60
+ "zod": "^3.24.1"
61
+ },
62
+ "devDependencies": {
63
+ "@types/qrcode-terminal": "^0.12.2"
64
+ }
65
+ }
@@ -0,0 +1,156 @@
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
+ })
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Auto-compaction: pre-flight summarize-fold of older history when the
3
+ * running token estimate breaches a configurable fraction of the model
4
+ * context window.
5
+ *
6
+ * Cost / quality model:
7
+ * - Compaction cost is one extra inference call (the summarize step) per
8
+ * fold. Default threshold 0.5 of contextWindow means a Qwen 1M session
9
+ * compacts ~once per 500K tokens of accumulated transcript.
10
+ * - Frozen prefix (system prompt + identity + persona + skills) is NEVER
11
+ * touched by compaction — caller-managed, designed to stay cache-warm.
12
+ * This module only operates on the `history` array (user/assistant pairs).
13
+ * - The summary is inserted as a single user-role message wrapped in
14
+ * `<previous-context-summary>...</previous-context-summary>` so the brain
15
+ * can recognize it as historical context, not a fresh request.
16
+ */
17
+
18
+ import type { BrainMessage } from './types'
19
+
20
+ export interface CompactionOpts {
21
+ /** Fraction of contextWindow (0-1) that triggers compaction. */
22
+ threshold: number
23
+ /** Model context window in tokens. */
24
+ contextWindow: number
25
+ /**
26
+ * Number of recent turns to keep verbatim AFTER the summary.
27
+ * Each "turn" is 2 messages (user + assistant), so keepRecent: 8 retains
28
+ * the last 16 messages.
29
+ */
30
+ keepRecent: number
31
+ }
32
+
33
+ export const DEFAULT_COMPACTION_OPTS: CompactionOpts = {
34
+ threshold: 0.5,
35
+ contextWindow: 1_000_000,
36
+ keepRecent: 8,
37
+ }
38
+
39
+ /**
40
+ * System prompt fed to the summarizer sub-call. Tuned to extract durable
41
+ * facts and decisions while dropping pleasantries; output is plain text with
42
+ * no preamble so the wrapper tag is the only structure around it.
43
+ */
44
+ export const SUMMARY_SYSTEM_PROMPT = `You are summarizing a conversation between an operator and an AI agent so the agent can keep working with context that fits in its budget.
45
+
46
+ Produce a tight, factual recap (3-8 sentences) preserving:
47
+ - Key facts the operator stated about themselves, their goals, and constraints.
48
+ - Decisions that were made and why.
49
+ - Tool outputs the agent referenced or relied on (URLs visited, balances read, files written).
50
+ - In-progress goals or tasks the agent is mid-flight on.
51
+
52
+ Drop pleasantries, repeated clarifications, verbose tool args, and redundant agent prose.
53
+
54
+ Output the summary text only. Do not add a preamble like "Here is the summary:" — start with the first fact.`
55
+
56
+ /**
57
+ * Heuristic token estimator: ~3.5 chars per token (slightly conservative for
58
+ * Qwen-style tokenizers on mixed content). Used as a fallback when the brain
59
+ * has no `usage.promptTokens` from the prior turn yet.
60
+ */
61
+ export function estimateTokens(messages: readonly BrainMessage[]): number {
62
+ let total = 0
63
+ for (const m of messages) {
64
+ const text = typeof m.content === 'string' ? m.content : ''
65
+ total += Math.ceil(text.length / 3.5)
66
+ // Rough overhead for tool_calls metadata (id, name, args JSON).
67
+ if (m.toolCalls) {
68
+ for (const tc of m.toolCalls) {
69
+ total += Math.ceil((tc.name?.length ?? 0) / 3.5)
70
+ const argStr = typeof tc.args === 'string' ? tc.args : JSON.stringify(tc.args ?? {})
71
+ total += Math.ceil(argStr.length / 3.5)
72
+ }
73
+ }
74
+ }
75
+ return total
76
+ }
77
+
78
+ /**
79
+ * Decide whether the next infer() call should compact first.
80
+ *
81
+ * Three conditions must hold:
82
+ * 1. History has more messages than `keepRecent * 2 + 4` (otherwise there's
83
+ * nothing meaningful to fold — keep is what we'd already keep).
84
+ * 2. Either the prior turn's `usage.promptTokens` (canonical) or the
85
+ * heuristic estimate exceeds `threshold * contextWindow`.
86
+ *
87
+ * Returns the trigger token count (the larger of usage and estimate) for the
88
+ * caller to log via the compaction event, or null when no compaction needed.
89
+ */
90
+ export function shouldCompact(
91
+ history: readonly BrainMessage[],
92
+ lastTurnPromptTokens: number | null,
93
+ opts: CompactionOpts,
94
+ ): number | null {
95
+ if (history.length < opts.keepRecent * 2 + 4) return null
96
+ const estimate = estimateTokens(history)
97
+ // We take the max of the prior turn's authoritative usage and the heuristic
98
+ // estimate. Usage alone misses messages appended since the last turn (the
99
+ // pending user msg), so the estimate is the conservative cap.
100
+ const tokens = Math.max(lastTurnPromptTokens ?? 0, estimate)
101
+ const limit = opts.threshold * opts.contextWindow
102
+ return tokens > limit ? tokens : null
103
+ }
104
+
105
+ export type SummarizeFn = (older: readonly BrainMessage[]) => Promise<string>
106
+
107
+ /**
108
+ * Fold older messages into a single `<previous-context-summary>` user
109
+ * message and return the new history (summary + last `keepRecent * 2`
110
+ * messages verbatim).
111
+ *
112
+ * If the history is already short enough that there are no "older" messages
113
+ * to fold, returns the input unchanged.
114
+ *
115
+ * Thrown errors from `summarize` propagate — caller decides whether to skip
116
+ * compaction this turn (best-effort) or fail the turn.
117
+ */
118
+ export async function compactHistory(
119
+ history: readonly BrainMessage[],
120
+ opts: CompactionOpts,
121
+ summarize: SummarizeFn,
122
+ ): Promise<BrainMessage[]> {
123
+ const recentCount = opts.keepRecent * 2
124
+ if (history.length <= recentCount) return [...history]
125
+ const recent = history.slice(-recentCount)
126
+ const older = history.slice(0, -recentCount)
127
+ if (older.length === 0) return [...history]
128
+ const summary = await summarize(older)
129
+ const wrapped = `<previous-context-summary>\n${summary.trim()}\n</previous-context-summary>`
130
+ return [{ role: 'user', content: wrapped }, ...recent]
131
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Hackathon demo fallback for the brain's LLM endpoint.
3
+ *
4
+ * So lyra runs out of the box with **no personal API key**: when neither
5
+ * `OPENAI_API_KEY` nor `LYRA_LLM_API_KEY` is set, the CLI and gateway route
6
+ * through this hosted, key-capped, rate-limited proxy (it holds the real key
7
+ * server-side). Users who set their own key bypass this entirely.
8
+ *
9
+ * NOTE: this is a public demo endpoint for the Sui Turing Test 2026. Remove
10
+ * or rotate it after the event (it gates a shared, spend-capped OpenAI key).
11
+ */
12
+ export const DEMO_LLM_BASE_URL = 'https://lyraai.space/api/llm/v1'
13
+ export const DEMO_LLM_TOKEN = 'lyra-demo-c3ce165b62c9c8a2f8a61324'
@@ -0,0 +1,235 @@
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
+ })