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,154 @@
|
|
|
1
|
+
import { type ChildProcess, spawn } from 'node:child_process'
|
|
2
|
+
import type { McpServerStdio } from './types'
|
|
3
|
+
|
|
4
|
+
interface JsonRpcRequest {
|
|
5
|
+
jsonrpc: '2.0'
|
|
6
|
+
id: number
|
|
7
|
+
method: string
|
|
8
|
+
params?: unknown
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface JsonRpcResponse {
|
|
12
|
+
jsonrpc: '2.0'
|
|
13
|
+
id: number
|
|
14
|
+
result?: unknown
|
|
15
|
+
error?: { code: number; message: string; data?: unknown }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface JsonRpcNotification {
|
|
19
|
+
jsonrpc: '2.0'
|
|
20
|
+
method: string
|
|
21
|
+
params?: unknown
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type JsonRpcMessage = JsonRpcResponse | JsonRpcNotification
|
|
25
|
+
|
|
26
|
+
const PROTOCOL_VERSION = '2024-11-05'
|
|
27
|
+
const CLIENT_INFO = { name: 'lyra', version: '0.8.1' }
|
|
28
|
+
|
|
29
|
+
export class McpStdioClient {
|
|
30
|
+
private proc: ChildProcess | null = null
|
|
31
|
+
private nextId = 1
|
|
32
|
+
private buffer = ''
|
|
33
|
+
private readonly pending = new Map<
|
|
34
|
+
number,
|
|
35
|
+
{ resolve: (v: unknown) => void; reject: (e: Error) => void }
|
|
36
|
+
>()
|
|
37
|
+
private starting: Promise<void> | null = null
|
|
38
|
+
|
|
39
|
+
constructor(public readonly server: McpServerStdio) {}
|
|
40
|
+
|
|
41
|
+
async ensureStarted(timeoutMs = 10_000): Promise<void> {
|
|
42
|
+
if (this.proc) return
|
|
43
|
+
if (this.starting) return this.starting
|
|
44
|
+
this.starting = this.spawnAndInitialize(timeoutMs)
|
|
45
|
+
try {
|
|
46
|
+
await this.starting
|
|
47
|
+
} finally {
|
|
48
|
+
this.starting = null
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private async spawnAndInitialize(timeoutMs: number): Promise<void> {
|
|
53
|
+
const proc = spawn(this.server.command, this.server.args ?? [], {
|
|
54
|
+
env: { ...process.env, ...(this.server.env ?? {}) },
|
|
55
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
56
|
+
})
|
|
57
|
+
this.proc = proc
|
|
58
|
+
proc.stdout?.setEncoding('utf8')
|
|
59
|
+
proc.stdout?.on('data', chunk => this.onStdout(chunk as string))
|
|
60
|
+
proc.on('error', err => this.failAll(err))
|
|
61
|
+
proc.on('exit', () => this.failAll(new Error(`mcp server '${this.server.name}' exited`)))
|
|
62
|
+
// Initialize handshake
|
|
63
|
+
const initPromise = this.request('initialize', {
|
|
64
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
65
|
+
capabilities: {},
|
|
66
|
+
clientInfo: CLIENT_INFO,
|
|
67
|
+
})
|
|
68
|
+
const timeout = new Promise<never>((_, reject) =>
|
|
69
|
+
setTimeout(() => reject(new Error(`mcp init timeout (${timeoutMs}ms)`)), timeoutMs),
|
|
70
|
+
)
|
|
71
|
+
await Promise.race([initPromise, timeout])
|
|
72
|
+
this.notify('notifications/initialized', {})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async listTools(): Promise<{ name: string; description?: string; inputSchema?: unknown }[]> {
|
|
76
|
+
await this.ensureStarted()
|
|
77
|
+
const result = (await this.request('tools/list', {})) as {
|
|
78
|
+
tools?: { name: string; description?: string; inputSchema?: unknown }[]
|
|
79
|
+
}
|
|
80
|
+
return result.tools ?? []
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async callTool(name: string, args: unknown): Promise<unknown> {
|
|
84
|
+
await this.ensureStarted()
|
|
85
|
+
return await this.request('tools/call', { name, arguments: args })
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
request(method: string, params: unknown): Promise<unknown> {
|
|
89
|
+
if (!this.proc?.stdin) throw new Error('mcp server not started')
|
|
90
|
+
const id = this.nextId++
|
|
91
|
+
const req: JsonRpcRequest = { jsonrpc: '2.0', id, method, params }
|
|
92
|
+
return new Promise<unknown>((resolve, reject) => {
|
|
93
|
+
this.pending.set(id, { resolve, reject })
|
|
94
|
+
this.proc!.stdin!.write(`${JSON.stringify(req)}\n`, err => {
|
|
95
|
+
if (err) {
|
|
96
|
+
this.pending.delete(id)
|
|
97
|
+
reject(err)
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
notify(method: string, params: unknown): void {
|
|
104
|
+
if (!this.proc?.stdin) return
|
|
105
|
+
const note: JsonRpcNotification = { jsonrpc: '2.0', method, params }
|
|
106
|
+
this.proc.stdin.write(`${JSON.stringify(note)}\n`)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
close(): void {
|
|
110
|
+
if (!this.proc) return
|
|
111
|
+
try {
|
|
112
|
+
this.proc.stdin?.end()
|
|
113
|
+
} catch {}
|
|
114
|
+
try {
|
|
115
|
+
this.proc.kill('SIGTERM')
|
|
116
|
+
} catch {}
|
|
117
|
+
this.proc = null
|
|
118
|
+
this.failAll(new Error('mcp client closed'))
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private onStdout(chunk: string): void {
|
|
122
|
+
this.buffer += chunk
|
|
123
|
+
let nl = this.buffer.indexOf('\n')
|
|
124
|
+
while (nl !== -1) {
|
|
125
|
+
const line = this.buffer.slice(0, nl).trim()
|
|
126
|
+
this.buffer = this.buffer.slice(nl + 1)
|
|
127
|
+
nl = this.buffer.indexOf('\n')
|
|
128
|
+
if (!line) continue
|
|
129
|
+
let parsed: JsonRpcMessage
|
|
130
|
+
try {
|
|
131
|
+
parsed = JSON.parse(line) as JsonRpcMessage
|
|
132
|
+
} catch {
|
|
133
|
+
continue
|
|
134
|
+
}
|
|
135
|
+
this.dispatch(parsed)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private dispatch(msg: JsonRpcMessage): void {
|
|
140
|
+
if ('id' in msg) {
|
|
141
|
+
const pending = this.pending.get(msg.id)
|
|
142
|
+
if (!pending) return
|
|
143
|
+
this.pending.delete(msg.id)
|
|
144
|
+
if (msg.error) pending.reject(new Error(`${msg.error.code}: ${msg.error.message}`))
|
|
145
|
+
else pending.resolve(msg.result ?? null)
|
|
146
|
+
}
|
|
147
|
+
// Notifications from the server are ignored for now.
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private failAll(err: Error): void {
|
|
151
|
+
for (const { reject } of this.pending.values()) reject(err)
|
|
152
|
+
this.pending.clear()
|
|
153
|
+
}
|
|
154
|
+
}
|
package/src/mcp/types.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP server config shape (compatible with Claude Code's `.mcp.json`).
|
|
3
|
+
*
|
|
4
|
+
* stdio: `{ command, args?, env? }`. lyra spawns a subprocess and speaks
|
|
5
|
+
* JSON-RPC over its stdin/stdout.
|
|
6
|
+
*
|
|
7
|
+
* http: `{ type: 'http', url, headers? }`. lyra posts JSON-RPC to the URL.
|
|
8
|
+
* Phase 9.2 ships stdio only; HTTP lands in 9.4 polish.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface McpServerStdio {
|
|
12
|
+
/** Server name used as the prefix in tool ids (`mcp.<name>.<tool>`). */
|
|
13
|
+
name: string
|
|
14
|
+
type?: 'stdio'
|
|
15
|
+
command: string
|
|
16
|
+
args?: string[]
|
|
17
|
+
env?: Record<string, string>
|
|
18
|
+
/** Replacement value for `${CLAUDE_PLUGIN_ROOT}` in args. Set when scanning a plugin cache. */
|
|
19
|
+
pluginRoot?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface McpServerHttp {
|
|
23
|
+
name: string
|
|
24
|
+
type: 'http'
|
|
25
|
+
url: string
|
|
26
|
+
headers?: Record<string, string>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type McpServerConfig = McpServerStdio | McpServerHttp
|
|
30
|
+
|
|
31
|
+
export interface McpToolMeta {
|
|
32
|
+
/** server name */
|
|
33
|
+
server: string
|
|
34
|
+
/** original (unprefixed) tool name advertised by the MCP server */
|
|
35
|
+
toolName: string
|
|
36
|
+
description: string
|
|
37
|
+
inputSchema: unknown
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface McpDiscoveryResult {
|
|
41
|
+
servers: McpServerConfig[]
|
|
42
|
+
/** Source path the server was discovered from (debug output only). */
|
|
43
|
+
sources: { server: string; path: string }[]
|
|
44
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test'
|
|
2
|
+
import { EditError, applyEdit } from './edit'
|
|
3
|
+
|
|
4
|
+
test('add on empty body inserts content', () => {
|
|
5
|
+
const out = applyEdit('', { action: 'add', newText: 'hello' })
|
|
6
|
+
expect(out).toBe('hello')
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
test('add appends with double newline on existing body', () => {
|
|
10
|
+
const out = applyEdit('first line', { action: 'add', newText: 'second line' })
|
|
11
|
+
expect(out).toBe('first line\n\nsecond line\n')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
test('replace substring', () => {
|
|
15
|
+
const out = applyEdit('foo bar baz', {
|
|
16
|
+
action: 'replace',
|
|
17
|
+
oldText: 'bar',
|
|
18
|
+
newText: 'qux',
|
|
19
|
+
})
|
|
20
|
+
expect(out).toBe('foo qux baz')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('replace throws when oldText missing', () => {
|
|
24
|
+
expect(() =>
|
|
25
|
+
applyEdit('foo bar', { action: 'replace', oldText: 'missing', newText: 'x' }),
|
|
26
|
+
).toThrow(EditError)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('replace throws when oldText is ambiguous', () => {
|
|
30
|
+
expect(() => applyEdit('foo foo', { action: 'replace', oldText: 'foo', newText: 'x' })).toThrow(
|
|
31
|
+
EditError,
|
|
32
|
+
)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('remove strips matching substring', () => {
|
|
36
|
+
const out = applyEdit('leading payload trailing', {
|
|
37
|
+
action: 'remove',
|
|
38
|
+
oldText: 'payload ',
|
|
39
|
+
})
|
|
40
|
+
expect(out).toBe('leading trailing')
|
|
41
|
+
})
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export type EditAction = 'add' | 'replace' | 'remove'
|
|
2
|
+
|
|
3
|
+
export interface EditOp {
|
|
4
|
+
action: EditAction
|
|
5
|
+
/** Required for replace/remove. Substring to match in the existing body. */
|
|
6
|
+
oldText?: string
|
|
7
|
+
/** Required for add/replace. Text to insert. */
|
|
8
|
+
newText?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class EditError extends Error {
|
|
12
|
+
constructor(
|
|
13
|
+
message: string,
|
|
14
|
+
readonly op: EditOp,
|
|
15
|
+
) {
|
|
16
|
+
super(message)
|
|
17
|
+
this.name = 'EditError'
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Apply a single edit op to `body`. Substring-based matching (hermes pattern),
|
|
23
|
+
* no IDs or line numbers. Returns the new body.
|
|
24
|
+
*/
|
|
25
|
+
export function applyEdit(body: string, op: EditOp): string {
|
|
26
|
+
switch (op.action) {
|
|
27
|
+
case 'add': {
|
|
28
|
+
if (op.newText === undefined) throw new EditError('add requires newText', op)
|
|
29
|
+
return body.length === 0 ? op.newText : `${body.trimEnd()}\n\n${op.newText}\n`
|
|
30
|
+
}
|
|
31
|
+
case 'replace': {
|
|
32
|
+
if (op.oldText === undefined || op.newText === undefined) {
|
|
33
|
+
throw new EditError('replace requires oldText AND newText', op)
|
|
34
|
+
}
|
|
35
|
+
const idx = locateUnique(body, op.oldText, op)
|
|
36
|
+
return body.slice(0, idx) + op.newText + body.slice(idx + op.oldText.length)
|
|
37
|
+
}
|
|
38
|
+
case 'remove': {
|
|
39
|
+
if (op.oldText === undefined) throw new EditError('remove requires oldText', op)
|
|
40
|
+
const idx = locateUnique(body, op.oldText, op)
|
|
41
|
+
return body.slice(0, idx) + body.slice(idx + op.oldText.length)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function locateUnique(body: string, needle: string, op: EditOp): number {
|
|
47
|
+
const idx = body.indexOf(needle)
|
|
48
|
+
if (idx < 0) throw new EditError('oldText not found in body', op)
|
|
49
|
+
if (body.indexOf(needle, idx + 1) >= 0) {
|
|
50
|
+
throw new EditError('oldText is not unique in body', op)
|
|
51
|
+
}
|
|
52
|
+
return idx
|
|
53
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { Ed25519Keypair } from '@mysten/sui/keypairs/ed25519'
|
|
3
|
+
import {
|
|
4
|
+
MEMORY_BLOB_VERSION,
|
|
5
|
+
decryptMemoryBytes,
|
|
6
|
+
deriveMemoryKey,
|
|
7
|
+
encryptMemoryBytes,
|
|
8
|
+
} from './encryption'
|
|
9
|
+
|
|
10
|
+
const genSecret = (): string => Ed25519Keypair.generate().getSecretKey()
|
|
11
|
+
|
|
12
|
+
describe('memory encryption', () => {
|
|
13
|
+
test('round-trip: encrypt + decrypt with same agent key', () => {
|
|
14
|
+
const agentPriv = genSecret()
|
|
15
|
+
const key = deriveMemoryKey(agentPriv)
|
|
16
|
+
const plaintext = new TextEncoder().encode('# identity\n\nagent-001 minted at block 42\n')
|
|
17
|
+
const blob = encryptMemoryBytes(plaintext, key)
|
|
18
|
+
expect(blob[0]).toBe(MEMORY_BLOB_VERSION)
|
|
19
|
+
const out = decryptMemoryBytes(blob, key)
|
|
20
|
+
expect(new TextDecoder().decode(out)).toBe(new TextDecoder().decode(plaintext))
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('different agent keys produce different ciphertexts (key separation)', () => {
|
|
24
|
+
const a = deriveMemoryKey(genSecret())
|
|
25
|
+
const b = deriveMemoryKey(genSecret())
|
|
26
|
+
const pt = new TextEncoder().encode('hello')
|
|
27
|
+
const ba = encryptMemoryBytes(pt, a)
|
|
28
|
+
const bb = encryptMemoryBytes(pt, b)
|
|
29
|
+
expect(Buffer.from(ba).equals(Buffer.from(bb))).toBe(false)
|
|
30
|
+
expect(() => decryptMemoryBytes(ba, b)).toThrow()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('two encrypts of same plaintext yield different ciphertexts (random IV)', () => {
|
|
34
|
+
const key = deriveMemoryKey(genSecret())
|
|
35
|
+
const pt = new TextEncoder().encode('same plaintext')
|
|
36
|
+
const a = encryptMemoryBytes(pt, key)
|
|
37
|
+
const b = encryptMemoryBytes(pt, key)
|
|
38
|
+
expect(Buffer.from(a).equals(Buffer.from(b))).toBe(false)
|
|
39
|
+
expect(new TextDecoder().decode(decryptMemoryBytes(a, key))).toBe('same plaintext')
|
|
40
|
+
expect(new TextDecoder().decode(decryptMemoryBytes(b, key))).toBe('same plaintext')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('tampered ciphertext fails GCM auth', () => {
|
|
44
|
+
const key = deriveMemoryKey(genSecret())
|
|
45
|
+
const blob = encryptMemoryBytes(new TextEncoder().encode('x'), key)
|
|
46
|
+
blob[blob.length - 1] = (blob[blob.length - 1] ?? 0) ^ 0xff
|
|
47
|
+
expect(() => decryptMemoryBytes(blob, key)).toThrow()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('rejects unsupported version byte', () => {
|
|
51
|
+
const key = deriveMemoryKey(genSecret())
|
|
52
|
+
const blob = encryptMemoryBytes(new TextEncoder().encode('x'), key)
|
|
53
|
+
blob[0] = 99
|
|
54
|
+
expect(() => decryptMemoryBytes(blob, key)).toThrow(/unsupported memory blob version/)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('rejects too-short blob', () => {
|
|
58
|
+
const key = deriveMemoryKey(genSecret())
|
|
59
|
+
expect(() => decryptMemoryBytes(new Uint8Array([1, 2, 3]), key)).toThrow(/too short/)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('determinism: same agent privkey always derives same key', () => {
|
|
63
|
+
const priv = genSecret()
|
|
64
|
+
const k1 = deriveMemoryKey(priv)
|
|
65
|
+
const k2 = deriveMemoryKey(priv)
|
|
66
|
+
expect(k1.equals(k2)).toBe(true)
|
|
67
|
+
})
|
|
68
|
+
})
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from 'node:crypto'
|
|
2
|
+
import { gunzipSync, gzipSync } from 'node:zlib'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Phase 6.7 memory file encryption.
|
|
6
|
+
*
|
|
7
|
+
* Key derivation: HKDF-SHA256(ikm = agent privkey bytes, info = "lyra-memory-aead-v1")
|
|
8
|
+
* → 32-byte AES-256-GCM key.
|
|
9
|
+
*
|
|
10
|
+
* Why agent privkey (not operator wallet)? Memory writes happen mid-chat —
|
|
11
|
+
* thousands of times in a long conversation. Asking the operator wallet to
|
|
12
|
+
* sign per write would be miserable for WC users. The agent privkey is
|
|
13
|
+
* already in RAM during the chat session (decrypted via operator at session
|
|
14
|
+
* start), so deriving a memory key from it is silent and fast.
|
|
15
|
+
*
|
|
16
|
+
* Recovery: anyone who can decrypt the keystore can derive this key, so the
|
|
17
|
+
* security envelope is the same as the keystore's.
|
|
18
|
+
*
|
|
19
|
+
* Format: v(1) || iv(12) || tag(16) || ciphertext (raw bytes, no JSON wrap).
|
|
20
|
+
* v=1: plaintext encrypted directly (legacy).
|
|
21
|
+
* v=2: plaintext gzip-compressed first then encrypted. Decryption gunzips
|
|
22
|
+
* after AES-GCM. Used for the activity-log slot where JSON content
|
|
23
|
+
* compresses 5-10x and the Walrus upload is the bottleneck.
|
|
24
|
+
*
|
|
25
|
+
* Both versions are backwards-compatible: decryptMemoryBytes dispatches on
|
|
26
|
+
* the leading version byte and reads either layout.
|
|
27
|
+
*/
|
|
28
|
+
export const MEMORY_BLOB_VERSION = 1 as const
|
|
29
|
+
export const MEMORY_BLOB_VERSION_GZIP = 2 as const
|
|
30
|
+
|
|
31
|
+
const HKDF_INFO = Buffer.from('lyra-memory-aead-v1', 'utf8')
|
|
32
|
+
const KEY_LEN = 32
|
|
33
|
+
const IV_LEN = 12
|
|
34
|
+
const TAG_LEN = 16
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Derive the memory AES key from the agent's secret. Accepts the raw secret
|
|
38
|
+
* bytes or a string (Sui bech32 secret `suiprivkey1...`, or legacy `0x` hex);
|
|
39
|
+
* strings are keyed by their UTF-8 bytes so any stable secret representation
|
|
40
|
+
* works. The security envelope is the keystore's: whoever can decrypt the
|
|
41
|
+
* keystore can re-derive this key.
|
|
42
|
+
*/
|
|
43
|
+
export function deriveMemoryKey(agentSecret: string | Uint8Array): Buffer {
|
|
44
|
+
const ikm =
|
|
45
|
+
typeof agentSecret === 'string' ? Buffer.from(agentSecret, 'utf8') : Buffer.from(agentSecret)
|
|
46
|
+
return Buffer.from(hkdfSync('sha256', ikm, Buffer.alloc(0), HKDF_INFO, KEY_LEN))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface EncryptOpts {
|
|
50
|
+
/**
|
|
51
|
+
* Gzip the plaintext before encrypting. Reduces blob size 5-10x on JSON-
|
|
52
|
+
* heavy content like the activity log. Costs a few ms of CPU per upload
|
|
53
|
+
* — fine because the network upload it saves is much slower. Default
|
|
54
|
+
* false to preserve byte-for-byte compatibility with v=1 callers.
|
|
55
|
+
*/
|
|
56
|
+
compress?: boolean
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function encryptMemoryBytes(
|
|
60
|
+
plaintext: Uint8Array,
|
|
61
|
+
key: Buffer,
|
|
62
|
+
opts: EncryptOpts = {},
|
|
63
|
+
): Uint8Array {
|
|
64
|
+
if (key.length !== KEY_LEN) throw new Error(`memory key must be ${KEY_LEN} bytes`)
|
|
65
|
+
const iv = randomBytes(IV_LEN)
|
|
66
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv)
|
|
67
|
+
const payload = opts.compress ? gzipSync(plaintext) : plaintext
|
|
68
|
+
const ct = Buffer.concat([cipher.update(payload), cipher.final()])
|
|
69
|
+
const tag = cipher.getAuthTag()
|
|
70
|
+
const version = opts.compress ? MEMORY_BLOB_VERSION_GZIP : MEMORY_BLOB_VERSION
|
|
71
|
+
return new Uint8Array(Buffer.concat([Buffer.from([version]), iv, tag, ct]))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function decryptMemoryBytes(blob: Uint8Array, key: Buffer): Uint8Array {
|
|
75
|
+
if (key.length !== KEY_LEN) throw new Error(`memory key must be ${KEY_LEN} bytes`)
|
|
76
|
+
const buf = Buffer.from(blob)
|
|
77
|
+
if (buf.length < 1 + IV_LEN + TAG_LEN) {
|
|
78
|
+
throw new Error(`memory blob too short: ${buf.length} bytes`)
|
|
79
|
+
}
|
|
80
|
+
const version = buf[0]
|
|
81
|
+
if (version !== MEMORY_BLOB_VERSION && version !== MEMORY_BLOB_VERSION_GZIP) {
|
|
82
|
+
throw new Error(`unsupported memory blob version: ${version}`)
|
|
83
|
+
}
|
|
84
|
+
const iv = buf.subarray(1, 1 + IV_LEN)
|
|
85
|
+
const tag = buf.subarray(1 + IV_LEN, 1 + IV_LEN + TAG_LEN)
|
|
86
|
+
const ct = buf.subarray(1 + IV_LEN + TAG_LEN)
|
|
87
|
+
const decipher = createDecipheriv('aes-256-gcm', key, iv)
|
|
88
|
+
decipher.setAuthTag(tag)
|
|
89
|
+
const decrypted = Buffer.concat([decipher.update(ct), decipher.final()])
|
|
90
|
+
if (version === MEMORY_BLOB_VERSION_GZIP) {
|
|
91
|
+
return new Uint8Array(gunzipSync(decrypted))
|
|
92
|
+
}
|
|
93
|
+
return new Uint8Array(decrypted)
|
|
94
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Read a file as bytes; return null on ENOENT, rethrow other errors.
|
|
5
|
+
* Used wherever an "absent file is fine, anything else is a bug" semantic
|
|
6
|
+
* is needed (memory sync, activity log sync, on-chain diff vs local).
|
|
7
|
+
*/
|
|
8
|
+
export async function readOrNull(path: string): Promise<Uint8Array | null> {
|
|
9
|
+
try {
|
|
10
|
+
return new Uint8Array(await readFile(path))
|
|
11
|
+
} catch (e) {
|
|
12
|
+
if ((e as NodeJS.ErrnoException).code === 'ENOENT') return null
|
|
13
|
+
throw e
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { readFile, rename, writeFile } from 'node:fs/promises'
|
|
2
|
+
import type { MemoryIndex, MemoryIndexEntry } from './types'
|
|
3
|
+
|
|
4
|
+
/** Max enforced by Claude Code conventions — loaded into every prompt. */
|
|
5
|
+
export const INDEX_LINE_LIMIT = 200
|
|
6
|
+
export const INDEX_BYTE_LIMIT = 25 * 1024
|
|
7
|
+
|
|
8
|
+
const ENTRY_RE = /^-\s*\[([^\]]+)\]\(([^)]+)\)(?:\s*[-—]\s*(.*))?$/
|
|
9
|
+
|
|
10
|
+
export function parseIndex(raw: string): MemoryIndex {
|
|
11
|
+
const lines = raw.split('\n')
|
|
12
|
+
const entries = new Map<string, MemoryIndexEntry>()
|
|
13
|
+
for (const line of lines) {
|
|
14
|
+
const m = line.match(ENTRY_RE)
|
|
15
|
+
if (m?.[1] && m[2]) {
|
|
16
|
+
const title = m[1]
|
|
17
|
+
const file = m[2]
|
|
18
|
+
const hook = m[3] ?? ''
|
|
19
|
+
entries.set(file, { file, title, hook: hook.trim() })
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return { lines, entries }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function stringifyIndex(index: MemoryIndex): string {
|
|
26
|
+
const joined = index.lines.join('\n')
|
|
27
|
+
return joined.endsWith('\n') ? joined : `${joined}\n`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function readIndexFile(path: string): Promise<MemoryIndex> {
|
|
31
|
+
const raw = await readFile(path, 'utf8').catch(e => {
|
|
32
|
+
if ((e as NodeJS.ErrnoException).code === 'ENOENT') return ''
|
|
33
|
+
throw e
|
|
34
|
+
})
|
|
35
|
+
return parseIndex(raw)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function writeIndexFile(path: string, index: MemoryIndex): Promise<void> {
|
|
39
|
+
const content = stringifyIndex(index)
|
|
40
|
+
if (content.length > INDEX_BYTE_LIMIT) {
|
|
41
|
+
throw new Error(`MEMORY.md exceeds ${INDEX_BYTE_LIMIT}-byte cap (got ${content.length})`)
|
|
42
|
+
}
|
|
43
|
+
if (index.lines.length > INDEX_LINE_LIMIT) {
|
|
44
|
+
throw new Error(`MEMORY.md exceeds ${INDEX_LINE_LIMIT}-line cap (got ${index.lines.length})`)
|
|
45
|
+
}
|
|
46
|
+
const tmp = `${path}.tmp-${process.pid}-${Date.now().toString(36)}`
|
|
47
|
+
await writeFile(tmp, content, 'utf8')
|
|
48
|
+
await rename(tmp, path)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function addEntryLine(index: MemoryIndex, entry: MemoryIndexEntry): MemoryIndex {
|
|
52
|
+
if (index.entries.has(entry.file)) return index
|
|
53
|
+
const line = `- [${entry.title}](${entry.file}) — ${entry.hook}`
|
|
54
|
+
const next: MemoryIndex = {
|
|
55
|
+
lines: [...index.lines, line],
|
|
56
|
+
entries: new Map(index.entries),
|
|
57
|
+
}
|
|
58
|
+
next.entries.set(entry.file, entry)
|
|
59
|
+
return next
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function removeEntryLine(index: MemoryIndex, file: string): MemoryIndex {
|
|
63
|
+
if (!index.entries.has(file)) return index
|
|
64
|
+
const filtered = index.lines.filter(line => {
|
|
65
|
+
const m = line.match(ENTRY_RE)
|
|
66
|
+
return !(m && m[2] === file)
|
|
67
|
+
})
|
|
68
|
+
const next: MemoryIndex = {
|
|
69
|
+
lines: filtered,
|
|
70
|
+
entries: new Map(index.entries),
|
|
71
|
+
}
|
|
72
|
+
next.entries.delete(file)
|
|
73
|
+
return next
|
|
74
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
|
2
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { ensureSyntheticIndexEntries } from './index-sync'
|
|
6
|
+
|
|
7
|
+
describe('ensureSyntheticIndexEntries', () => {
|
|
8
|
+
let memoryDir: string
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
memoryDir = mkdtempSync(join(tmpdir(), 'index-sync-'))
|
|
12
|
+
mkdirSync(join(memoryDir, 'agent'), { recursive: true })
|
|
13
|
+
mkdirSync(join(memoryDir, 'user'), { recursive: true })
|
|
14
|
+
writeFileSync(join(memoryDir, 'MEMORY.md'), '# Memory\n\n', 'utf8')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
if (memoryDir) rmSync(memoryDir, { recursive: true, force: true })
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('adds entries for files that exist on disk with frontmatter descriptions', async () => {
|
|
22
|
+
writeFileSync(
|
|
23
|
+
join(memoryDir, 'agent', 'identity.md'),
|
|
24
|
+
'---\nname: identity\ndescription: Auto-written agent identity facts\ntype: agent-identity\n---\n# id',
|
|
25
|
+
)
|
|
26
|
+
writeFileSync(
|
|
27
|
+
join(memoryDir, 'agent', 'persona.md'),
|
|
28
|
+
'---\nname: persona\ndescription: Voice + behavior style\ntype: agent-persona\n---\nbody',
|
|
29
|
+
)
|
|
30
|
+
writeFileSync(
|
|
31
|
+
join(memoryDir, 'user', 'profile.md'),
|
|
32
|
+
'---\nname: profile\ndescription: User profile\ntype: user\n---\nbody',
|
|
33
|
+
)
|
|
34
|
+
const r = await ensureSyntheticIndexEntries(memoryDir)
|
|
35
|
+
expect(r.added).toEqual(['agent/identity.md', 'agent/persona.md', 'user/profile.md'])
|
|
36
|
+
expect(r.skipped).toEqual([])
|
|
37
|
+
const idx = readFileSync(join(memoryDir, 'MEMORY.md'), 'utf8')
|
|
38
|
+
expect(idx).toContain('agent/identity.md')
|
|
39
|
+
expect(idx).toContain('Auto-written agent identity facts')
|
|
40
|
+
expect(idx).toContain('agent/persona.md')
|
|
41
|
+
expect(idx).toContain('user/profile.md')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('is idempotent — second call adds nothing', async () => {
|
|
45
|
+
writeFileSync(
|
|
46
|
+
join(memoryDir, 'agent', 'identity.md'),
|
|
47
|
+
'---\nname: identity\ndescription: id desc\ntype: agent-identity\n---\nbody',
|
|
48
|
+
)
|
|
49
|
+
await ensureSyntheticIndexEntries(memoryDir)
|
|
50
|
+
const r2 = await ensureSyntheticIndexEntries(memoryDir)
|
|
51
|
+
expect(r2.added).toEqual([])
|
|
52
|
+
expect(r2.skipped).toContain('agent/identity.md')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('skips files that do not exist on disk', async () => {
|
|
56
|
+
// Only persona exists, no identity.md or profile.md.
|
|
57
|
+
writeFileSync(
|
|
58
|
+
join(memoryDir, 'agent', 'persona.md'),
|
|
59
|
+
'---\nname: persona\ndescription: voice\ntype: agent-persona\n---\nbody',
|
|
60
|
+
)
|
|
61
|
+
const r = await ensureSyntheticIndexEntries(memoryDir)
|
|
62
|
+
expect(r.added).toEqual(['agent/persona.md'])
|
|
63
|
+
expect(r.skipped).toEqual(['agent/identity.md', 'user/profile.md'])
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('falls back to filename title when frontmatter is missing', async () => {
|
|
67
|
+
writeFileSync(join(memoryDir, 'agent', 'identity.md'), 'no frontmatter here')
|
|
68
|
+
const r = await ensureSyntheticIndexEntries(memoryDir)
|
|
69
|
+
expect(r.added).toEqual(['agent/identity.md'])
|
|
70
|
+
const idx = readFileSync(join(memoryDir, 'MEMORY.md'), 'utf8')
|
|
71
|
+
expect(idx).toContain('agent/identity.md')
|
|
72
|
+
expect(idx).toContain('identity')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('no-ops gracefully when MEMORY.md is missing', async () => {
|
|
76
|
+
rmSync(join(memoryDir, 'MEMORY.md'))
|
|
77
|
+
const r = await ensureSyntheticIndexEntries(memoryDir)
|
|
78
|
+
expect(r.added).toEqual([])
|
|
79
|
+
expect(r.skipped.length).toBe(3)
|
|
80
|
+
})
|
|
81
|
+
})
|