lyra-core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/README.md +24 -0
  2. package/package.json +65 -0
  3. package/src/brain/compaction.test.ts +156 -0
  4. package/src/brain/compaction.ts +131 -0
  5. package/src/brain/demo-endpoint.ts +13 -0
  6. package/src/brain/frozen-prefix.test.ts +235 -0
  7. package/src/brain/frozen-prefix.ts +320 -0
  8. package/src/brain/history-persist.test.ts +129 -0
  9. package/src/brain/history-persist.ts +154 -0
  10. package/src/brain/index.ts +44 -0
  11. package/src/brain/openai-brain.test.ts +61 -0
  12. package/src/brain/openai-brain.ts +544 -0
  13. package/src/brain/sanitize.test.ts +27 -0
  14. package/src/brain/sanitize.ts +23 -0
  15. package/src/brain/stub.ts +20 -0
  16. package/src/brain/types.ts +129 -0
  17. package/src/chain.ts +35 -0
  18. package/src/claude-plugins/discovery.test.ts +71 -0
  19. package/src/claude-plugins/discovery.ts +152 -0
  20. package/src/claude-plugins/index.ts +6 -0
  21. package/src/claude-plugins/types.ts +38 -0
  22. package/src/commands/index.ts +16 -0
  23. package/src/commands/registry.test.ts +186 -0
  24. package/src/commands/registry.ts +255 -0
  25. package/src/config.ts +201 -0
  26. package/src/economy/index.ts +6 -0
  27. package/src/events/index.ts +4 -0
  28. package/src/events/listeners.ts +37 -0
  29. package/src/events/queue.test.ts +43 -0
  30. package/src/events/queue.ts +63 -0
  31. package/src/events/router.ts +42 -0
  32. package/src/events/types.ts +28 -0
  33. package/src/format.ts +13 -0
  34. package/src/index.test.ts +6 -0
  35. package/src/index.ts +314 -0
  36. package/src/locks.test.ts +259 -0
  37. package/src/locks.ts +233 -0
  38. package/src/mcp/discovery.test.ts +97 -0
  39. package/src/mcp/discovery.ts +150 -0
  40. package/src/mcp/index.ts +10 -0
  41. package/src/mcp/manager.test.ts +97 -0
  42. package/src/mcp/manager.ts +110 -0
  43. package/src/mcp/stdio-client.ts +154 -0
  44. package/src/mcp/types.ts +44 -0
  45. package/src/memory/edit.test.ts +41 -0
  46. package/src/memory/edit.ts +53 -0
  47. package/src/memory/encryption.test.ts +68 -0
  48. package/src/memory/encryption.ts +94 -0
  49. package/src/memory/fs-util.ts +15 -0
  50. package/src/memory/index-file.ts +74 -0
  51. package/src/memory/index-sync.test.ts +81 -0
  52. package/src/memory/index-sync.ts +99 -0
  53. package/src/memory/index.ts +58 -0
  54. package/src/memory/list-tool.ts +105 -0
  55. package/src/memory/pack-blob.test.ts +90 -0
  56. package/src/memory/pack-blob.ts +120 -0
  57. package/src/memory/pack-gather.test.ts +110 -0
  58. package/src/memory/pack-gather.ts +112 -0
  59. package/src/memory/parser.test.ts +29 -0
  60. package/src/memory/parser.ts +20 -0
  61. package/src/memory/path-drift.test.ts +71 -0
  62. package/src/memory/read-tool-fallback.test.ts +54 -0
  63. package/src/memory/read-tool.ts +198 -0
  64. package/src/memory/save-tool.test.ts +193 -0
  65. package/src/memory/save-tool.ts +189 -0
  66. package/src/memory/scan.test.ts +24 -0
  67. package/src/memory/scan.ts +63 -0
  68. package/src/memory/topic.ts +32 -0
  69. package/src/memory/types.ts +49 -0
  70. package/src/migration/index.ts +6 -0
  71. package/src/migration/option3-crypto.test.ts +106 -0
  72. package/src/migration/option3-crypto.ts +143 -0
  73. package/src/pairing.test.ts +212 -0
  74. package/src/pairing.ts +285 -0
  75. package/src/paths.ts +70 -0
  76. package/src/permission/dangerous.ts +105 -0
  77. package/src/permission/env-redact.ts +54 -0
  78. package/src/permission/index.ts +16 -0
  79. package/src/permission/path-guard.ts +114 -0
  80. package/src/permission/permission.test.ts +299 -0
  81. package/src/permission/service.ts +191 -0
  82. package/src/plugins/context.ts +226 -0
  83. package/src/plugins/hooks.ts +81 -0
  84. package/src/plugins/index.ts +24 -0
  85. package/src/plugins/plugins.test.ts +196 -0
  86. package/src/plugins/tool-search.ts +49 -0
  87. package/src/public/card.test.ts +70 -0
  88. package/src/public/card.ts +67 -0
  89. package/src/runtime/activity.ts +29 -0
  90. package/src/runtime/index.ts +7 -0
  91. package/src/runtime/runtime.test.ts +55 -0
  92. package/src/runtime/runtime.ts +126 -0
  93. package/src/sandbox/credentials.ts +25 -0
  94. package/src/sandbox/docker.ts +389 -0
  95. package/src/sandbox/factory.ts +99 -0
  96. package/src/sandbox/index.ts +15 -0
  97. package/src/sandbox/linux.ts +141 -0
  98. package/src/sandbox/local.ts +19 -0
  99. package/src/sandbox/macos.ts +71 -0
  100. package/src/sandbox/sandbox.test.ts +386 -0
  101. package/src/sandbox/seatbelt-profile.ts +139 -0
  102. package/src/sandbox/types.ts +129 -0
  103. package/src/skills/index.ts +8 -0
  104. package/src/skills/scanner.test.ts +137 -0
  105. package/src/skills/scanner.ts +257 -0
  106. package/src/skills/triggers.test.ts +77 -0
  107. package/src/skills/triggers.ts +78 -0
  108. package/src/skills/types.ts +37 -0
  109. package/src/storage/encryption.test.ts +30 -0
  110. package/src/storage/encryption.ts +87 -0
  111. package/src/storage/factory.ts +31 -0
  112. package/src/storage/index.ts +11 -0
  113. package/src/storage/local-stub.ts +70 -0
  114. package/src/storage/sqlite.test.ts +29 -0
  115. package/src/storage/sqlite.ts +95 -0
  116. package/src/storage/types.ts +21 -0
  117. package/src/tools/escalation.test.ts +348 -0
  118. package/src/tools/escalation.ts +200 -0
  119. package/src/tools/index.ts +11 -0
  120. package/src/tools/registry.test.ts +70 -0
  121. package/src/tools/registry.ts +152 -0
  122. package/src/tools/types.ts +65 -0
  123. package/src/tools/zod-helpers.test.ts +63 -0
  124. package/src/tools/zod-helpers.ts +36 -0
  125. package/src/tools/zod-schema.ts +99 -0
  126. package/src/wallet/drain.test.ts +41 -0
  127. package/src/wallet/drain.ts +76 -0
  128. package/src/wallet/eoa.ts +61 -0
  129. package/src/wallet/index.ts +14 -0
  130. package/src/wallet/keystore.test.ts +17 -0
  131. package/src/wallet/keystore.ts +50 -0
@@ -0,0 +1,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
+ }
@@ -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
+ })