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,99 @@
1
+ import { readFile, stat } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import matter from 'gray-matter'
4
+ import { addEntryLine, readIndexFile, writeIndexFile } from './index-file'
5
+
6
+ /**
7
+ * v0.23.0: MEMORY.md historically only listed user-partition emergent topic
8
+ * files; the agent-partition meta-files (identity, persona) were anchored to
9
+ * the iNFT but invisible to brain enumeration via the index. Result: the
10
+ * brain's `memory.read name=identity` would only succeed via the slug fallback
11
+ * branch; `memory.list` would report agent[] files but the brain wouldn't
12
+ * see them in narrative MEMORY.md prose.
13
+ *
14
+ * This module adds synthetic top-of-index entries for the canonical
15
+ * agent-partition + the user/profile.md anchor whenever those files exist
16
+ * on disk. Idempotent (matches by file path). Runs at boot-restore and at
17
+ * every sync.doFlush() so the index stays current.
18
+ */
19
+ export interface SyntheticIndexFile {
20
+ /** Path relative to memoryDir, e.g. `agent/identity.md`. */
21
+ file: string
22
+ /** Title used when frontmatter `name` is absent. */
23
+ fallbackTitle: string
24
+ }
25
+
26
+ export const STANDARD_SYNTHETIC_INDEX_FILES: readonly SyntheticIndexFile[] = [
27
+ { file: 'agent/identity.md', fallbackTitle: 'identity' },
28
+ { file: 'agent/persona.md', fallbackTitle: 'persona' },
29
+ { file: 'user/profile.md', fallbackTitle: 'profile' },
30
+ ]
31
+
32
+ export interface SyntheticIndexResult {
33
+ added: string[]
34
+ skipped: string[]
35
+ }
36
+
37
+ export async function ensureSyntheticIndexEntries(
38
+ memoryDir: string,
39
+ files: readonly SyntheticIndexFile[] = STANDARD_SYNTHETIC_INDEX_FILES,
40
+ ): Promise<SyntheticIndexResult> {
41
+ const indexPath = join(memoryDir, 'MEMORY.md')
42
+ let index: Awaited<ReturnType<typeof readIndexFile>>
43
+ try {
44
+ index = await readIndexFile(indexPath)
45
+ } catch {
46
+ // Index file missing or unreadable — skip silently. seedStarterMemoryFiles
47
+ // creates it at init; existing agents that pre-date that path may need a
48
+ // one-time backfill via migration.
49
+ return { added: [], skipped: files.map(f => f.file) }
50
+ }
51
+
52
+ const added: string[] = []
53
+ const skipped: string[] = []
54
+
55
+ for (const f of files) {
56
+ if (index.entries.has(f.file)) {
57
+ skipped.push(f.file)
58
+ continue
59
+ }
60
+ const fsPath = join(memoryDir, f.file)
61
+ if (!(await fileExists(fsPath))) {
62
+ skipped.push(f.file)
63
+ continue
64
+ }
65
+ let title = f.fallbackTitle
66
+ let description: string | null = null
67
+ try {
68
+ const content = await readFile(fsPath, 'utf8')
69
+ const head = content.length > 4096 ? content.slice(0, 4096) : content
70
+ const parsed = matter(head)
71
+ const fm = parsed.data as { name?: string; description?: string }
72
+ if (fm.name && typeof fm.name === 'string') title = fm.name
73
+ if (fm.description && typeof fm.description === 'string') description = fm.description
74
+ } catch {
75
+ // bad frontmatter — fall back to filename
76
+ }
77
+ index = addEntryLine(index, {
78
+ file: f.file,
79
+ title,
80
+ hook: description ?? title,
81
+ })
82
+ added.push(f.file)
83
+ }
84
+
85
+ if (added.length > 0) {
86
+ await writeIndexFile(indexPath, index)
87
+ }
88
+
89
+ return { added, skipped }
90
+ }
91
+
92
+ async function fileExists(path: string): Promise<boolean> {
93
+ try {
94
+ const s = await stat(path)
95
+ return s.isFile() && s.size > 0
96
+ } catch {
97
+ return false
98
+ }
99
+ }
@@ -0,0 +1,58 @@
1
+ export type {
2
+ MemoryType,
3
+ MemoryPartition,
4
+ MemoryFrontmatter,
5
+ MemoryTopic,
6
+ MemoryIndexEntry,
7
+ MemoryIndex,
8
+ } from './types'
9
+ export { MEMORY_TYPES } from './types'
10
+ export { parseTopic, stringifyTopic } from './parser'
11
+ export { scanForThreats, type ThreatScanResult } from './scan'
12
+ export { applyEdit, EditError, type EditOp, type EditAction } from './edit'
13
+ export {
14
+ parseIndex,
15
+ stringifyIndex,
16
+ readIndexFile,
17
+ writeIndexFile,
18
+ addEntryLine,
19
+ removeEntryLine,
20
+ INDEX_LINE_LIMIT,
21
+ INDEX_BYTE_LIMIT,
22
+ } from './index-file'
23
+ export { readTopic, writeTopic, topicPath } from './topic'
24
+ export { makeMemorySaveTool, type MemorySaveArgs } from './save-tool'
25
+ export { makeMemoryReadTool, type MemoryReadArgs } from './read-tool'
26
+ export {
27
+ makeMemoryListTool,
28
+ type MemoryListArgs,
29
+ type MemoryListAgentFile,
30
+ } from './list-tool'
31
+ export {
32
+ ensureSyntheticIndexEntries,
33
+ STANDARD_SYNTHETIC_INDEX_FILES,
34
+ type SyntheticIndexFile,
35
+ type SyntheticIndexResult,
36
+ } from './index-sync'
37
+ export {
38
+ MEMORY_BLOB_VERSION,
39
+ deriveMemoryKey,
40
+ encryptMemoryBytes,
41
+ decryptMemoryBytes,
42
+ } from './encryption'
43
+ export { readOrNull } from './fs-util'
44
+ export {
45
+ PACK_BLOB_VERSION,
46
+ encodePackBlob,
47
+ decodePackBlob,
48
+ isV2Envelope,
49
+ type PackBlob,
50
+ type EncodePackOpts,
51
+ } from './pack-blob'
52
+ export {
53
+ gatherAgentPack,
54
+ gatherUserPack,
55
+ writeAgentPack,
56
+ writeUserPack,
57
+ type GatherResult,
58
+ } from './pack-gather'
@@ -0,0 +1,105 @@
1
+ import { readFile, readdir, stat } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import matter from 'gray-matter'
4
+ import { z } from 'zod'
5
+ import { agentPaths } from '../paths'
6
+ import type { ToolDef } from '../tools/types'
7
+
8
+ /**
9
+ * `memory.list` — enumerate every memory file the agent has stored locally.
10
+ *
11
+ * Returns two sections:
12
+ * - `agent[]`: files under `memory/agent/` (identity, persona, learned-*)
13
+ * - `user[]`: files under `memory/user/` (feedback, project, reference, profile)
14
+ *
15
+ * Use when the operator asks to enumerate what the agent knows. `memory.read`
16
+ * fetches individual file bodies; this tool just lists what's available.
17
+ */
18
+ const listSchema = z.object({})
19
+
20
+ export type MemoryListArgs = z.infer<typeof listSchema>
21
+
22
+ export interface MemoryListAgentFile {
23
+ file: string
24
+ title: string
25
+ description: string | null
26
+ bytes: number
27
+ }
28
+
29
+ export interface MakeMemoryListToolArgs {
30
+ agentId: string
31
+ agentDir?: string
32
+ }
33
+
34
+ export function makeMemoryListTool(opts: MakeMemoryListToolArgs): ToolDef<MemoryListArgs> {
35
+ const memDir = opts.agentDir
36
+ ? join(opts.agentDir, 'memory')
37
+ : agentPaths.agent(opts.agentId).memoryDir
38
+ return {
39
+ name: 'memory.list',
40
+ description:
41
+ "Enumerate every memory file the agent has stored (agent + user partitions). Call when the operator asks 'show me all your memory' / 'what do you remember' / 'list everything you have stored'. Returns two sections: agent (identity, persona, learned-*) and user (feedback, project, reference, profile).",
42
+ schema: listSchema,
43
+ handler: async () => {
44
+ const [agentFiles, userFiles] = await Promise.all([
45
+ listPartition(memDir, 'agent'),
46
+ listPartition(memDir, 'user'),
47
+ ])
48
+ return {
49
+ ok: true,
50
+ data: {
51
+ agent: agentFiles,
52
+ user: userFiles,
53
+ },
54
+ }
55
+ },
56
+ }
57
+ }
58
+
59
+ async function listPartition(
60
+ memDir: string,
61
+ partition: 'agent' | 'user',
62
+ ): Promise<MemoryListAgentFile[]> {
63
+ const dir = join(memDir, partition)
64
+ let names: string[]
65
+ try {
66
+ names = await readdir(dir)
67
+ } catch {
68
+ return []
69
+ }
70
+ const results = await Promise.all(
71
+ names
72
+ .filter(n => n.endsWith('.md'))
73
+ .map(async name => {
74
+ const filePath = join(dir, name)
75
+ try {
76
+ const [statResult, content] = await Promise.all([
77
+ stat(filePath),
78
+ readFile(filePath, 'utf8'),
79
+ ])
80
+ if (!statResult.isFile()) return null
81
+ // gray-matter on first 4KB is enough for frontmatter parse.
82
+ const head = content.length > 4096 ? content.slice(0, 4096) : content
83
+ let title = name.replace(/\.md$/, '')
84
+ let description: string | null = null
85
+ try {
86
+ const parsed = matter(head)
87
+ const fm = parsed.data as { name?: string; description?: string }
88
+ if (fm.name && typeof fm.name === 'string') title = fm.name
89
+ if (fm.description && typeof fm.description === 'string') description = fm.description
90
+ } catch {
91
+ // Bad frontmatter — fall back to filename.
92
+ }
93
+ return {
94
+ file: `${partition}/${name}`,
95
+ title,
96
+ description,
97
+ bytes: statResult.size,
98
+ } satisfies MemoryListAgentFile
99
+ } catch {
100
+ return null
101
+ }
102
+ }),
103
+ )
104
+ return results.filter((r): r is MemoryListAgentFile => r !== null)
105
+ }
@@ -0,0 +1,90 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { PACK_BLOB_VERSION, decodePackBlob, encodePackBlob, isV2Envelope } from './pack-blob'
3
+
4
+ describe('pack-blob v2 envelope', () => {
5
+ test('encode + decode round-trips single root', () => {
6
+ const bytes = encodePackBlob({ root: '# MEMORY.md\n\nentries' })
7
+ expect(isV2Envelope(bytes)).toBe(true)
8
+ const blob = decodePackBlob(bytes)
9
+ expect(blob.v).toBe(PACK_BLOB_VERSION)
10
+ expect(blob.root).toBe('# MEMORY.md\n\nentries')
11
+ expect(blob.files).toEqual({})
12
+ })
13
+
14
+ test('encode + decode round-trips with files map', () => {
15
+ const bytes = encodePackBlob({
16
+ root: '# profile',
17
+ files: {
18
+ 'operator-preferences.md': '# Operator Preferences\n\ndark mode',
19
+ 'hackathon-deadline.md': 'May 16 2026',
20
+ },
21
+ })
22
+ const blob = decodePackBlob(bytes)
23
+ expect(blob.root).toBe('# profile')
24
+ expect(blob.files['operator-preferences.md']).toContain('dark mode')
25
+ expect(blob.files['hackathon-deadline.md']).toBe('May 16 2026')
26
+ })
27
+
28
+ test('encode rejects unsafe filenames', () => {
29
+ expect(() => encodePackBlob({ root: 'x', files: { '../etc/passwd.md': 'hax' } })).toThrow(
30
+ /unsafe filename/,
31
+ )
32
+ expect(() => encodePackBlob({ root: 'x', files: { 'no-dot-md': 'x' } })).toThrow(
33
+ /unsafe filename/,
34
+ )
35
+ expect(() => encodePackBlob({ root: 'x', files: { 'UPPERCASE.md': 'x' } })).toThrow(
36
+ /unsafe filename/,
37
+ )
38
+ expect(() => encodePackBlob({ root: 'x', files: { 'has spaces.md': 'x' } })).toThrow(
39
+ /unsafe filename/,
40
+ )
41
+ expect(() => encodePackBlob({ root: 'x', files: { '.md': 'x' } })).toThrow(/unsafe filename/)
42
+ })
43
+
44
+ test('isV2Envelope rejects legacy markdown', () => {
45
+ const md = new TextEncoder().encode('# Heading\n\ncontent')
46
+ expect(isV2Envelope(md)).toBe(false)
47
+ })
48
+
49
+ test('isV2Envelope rejects v1 JSON without v:2', () => {
50
+ const v1 = new TextEncoder().encode('{"version":1,"data":"x"}')
51
+ expect(isV2Envelope(v1)).toBe(false)
52
+ })
53
+
54
+ test('isV2Envelope rejects garbage', () => {
55
+ const junk = new Uint8Array([0xff, 0x00, 0xab, 0xcd])
56
+ expect(isV2Envelope(junk)).toBe(false)
57
+ expect(isV2Envelope(new Uint8Array(0))).toBe(false)
58
+ })
59
+
60
+ test('isV2Envelope tolerates leading whitespace', () => {
61
+ const bytes = new TextEncoder().encode(' {"v":2,"root":"x","files":{}}')
62
+ expect(isV2Envelope(bytes)).toBe(true)
63
+ })
64
+
65
+ test('decode rejects mismatched version', () => {
66
+ const bytes = new TextEncoder().encode('{"v":999,"root":"x"}')
67
+ expect(() => decodePackBlob(bytes)).toThrow(/expected v=2/)
68
+ })
69
+
70
+ test('decode tolerates unsafe filenames by dropping them', () => {
71
+ const bytes = new TextEncoder().encode(
72
+ '{"v":2,"root":"x","files":{"ok.md":"y","../bad.md":"hax"}}',
73
+ )
74
+ const blob = decodePackBlob(bytes)
75
+ expect(blob.files['ok.md']).toBe('y')
76
+ expect(blob.files['../bad.md']).toBeUndefined()
77
+ })
78
+
79
+ test('handles large blobs (~100 small files)', () => {
80
+ const files: Record<string, string> = {}
81
+ for (let i = 0; i < 100; i++) {
82
+ files[`learned-${i}.md`] = `fact ${i}\n`.repeat(50)
83
+ }
84
+ const bytes = encodePackBlob({ root: '# MEMORY', files })
85
+ expect(bytes.length).toBeGreaterThan(1000)
86
+ const blob = decodePackBlob(bytes)
87
+ expect(Object.keys(blob.files).length).toBe(100)
88
+ expect(blob.files['learned-50.md']).toContain('fact 50')
89
+ })
90
+ })
@@ -0,0 +1,120 @@
1
+ /**
2
+ * v0.24.0: versioned envelope format for the memory-index (slot 0) and
3
+ * profile (slot 3) blobs. The legacy v1 layout stored a single markdown
4
+ * file's raw text. The v2 envelope wraps the root file's content plus
5
+ * an arbitrary map of additional sibling files so the harness can anchor
6
+ * the whole partition with one slot per partition.
7
+ *
8
+ * Why: pre-v0.24.0, only the 6 hard-coded `RESTORE_TARGETS` files survived
9
+ * reprovision. Every other `agent/*.md` and `user/*.md` was local-only
10
+ * scratchpad, and MEMORY.md retained dangling references after a fresh
11
+ * sandbox boot. The iNFT contract caps slots at 6 per token and is
12
+ * immutable, so adding new slots is impossible without re-minting every
13
+ * existing agent. The fix: extend the encoding of slots 0 + 3 without
14
+ * touching the contract.
15
+ *
16
+ * Envelope shape (plaintext, pre-encryption):
17
+ *
18
+ * { "v": 2,
19
+ * "root": "<markdown body of the canonical file>",
20
+ * "files": { "<filename>.md": "<markdown body>", ... } }
21
+ *
22
+ * - For slot 0 (memory-index, agent key): `root` is MEMORY.md text.
23
+ * `files` keys are paths under `memory/agent/` (e.g. `learned-foo.md`).
24
+ * `identity.md` and `persona.md` are NOT packed here — they keep their
25
+ * own slots (1, 2).
26
+ * - For slot 3 (profile, operator PROFILE key): `root` is profile.md text.
27
+ * `files` keys are paths under `memory/user/` (e.g.
28
+ * `operator-preferences.md`). `profile.md` itself is NOT a `files` key
29
+ * (its content is in `root`).
30
+ *
31
+ * Backwards compat:
32
+ *
33
+ * - `isV2Envelope(bytes)`: cheap byte sniff — first non-whitespace char
34
+ * must be `{` AND the parsed object must have `"v": 2`.
35
+ * - Decoders fall through to legacy v1 (raw markdown) when sniff fails.
36
+ * - Encoders default to v2; pass `legacy: true` for the old single-file
37
+ * format if a caller specifically needs v1 wire-compat.
38
+ *
39
+ * Filename sanitization:
40
+ *
41
+ * Keys in `files` must match `^[a-z0-9][a-z0-9._-]{0,63}\.md$` to keep
42
+ * the pack format predictable. The brain's slug generator (`toSlug` in
43
+ * `save-tool.ts`) already produces filenames that match. Unsafe names
44
+ * (path traversal, absolute paths, weird chars) are rejected at encode
45
+ * time.
46
+ */
47
+
48
+ const SAFE_NAME = /^[a-z0-9][a-z0-9._-]{0,63}\.md$/
49
+
50
+ export const PACK_BLOB_VERSION = 2 as const
51
+
52
+ export interface PackBlob {
53
+ v: typeof PACK_BLOB_VERSION
54
+ /** Root file content (MEMORY.md for slot 0, profile.md for slot 3). */
55
+ root: string
56
+ /** Additional packed files keyed by filename. May be empty. */
57
+ files: Record<string, string>
58
+ }
59
+
60
+ export interface EncodePackOpts {
61
+ root: string
62
+ files?: Record<string, string>
63
+ }
64
+
65
+ /** Encode a pack blob to UTF-8 bytes ready for AES-GCM encryption. */
66
+ export function encodePackBlob(opts: EncodePackOpts): Uint8Array {
67
+ const files: Record<string, string> = {}
68
+ for (const [name, content] of Object.entries(opts.files ?? {})) {
69
+ if (!SAFE_NAME.test(name)) {
70
+ throw new Error(`pack-blob: unsafe filename ${JSON.stringify(name)}`)
71
+ }
72
+ files[name] = content
73
+ }
74
+ const blob: PackBlob = { v: PACK_BLOB_VERSION, root: opts.root, files }
75
+ return new TextEncoder().encode(JSON.stringify(blob))
76
+ }
77
+
78
+ /**
79
+ * Returns true if `bytes` looks like a v2 envelope (starts with `{` and
80
+ * parses to `{ v: 2 }`). Cheap enough to call on every restore.
81
+ */
82
+ export function isV2Envelope(bytes: Uint8Array): boolean {
83
+ if (bytes.length < 8) return false
84
+ for (let i = 0; i < bytes.length && i < 16; i++) {
85
+ const b = bytes[i] as number
86
+ if (b === 0x7b /* { */) break
87
+ if (b === 0x20 || b === 0x09 || b === 0x0a || b === 0x0d) continue
88
+ return false
89
+ }
90
+ try {
91
+ const text = new TextDecoder().decode(bytes)
92
+ const parsed = JSON.parse(text) as { v?: unknown }
93
+ return parsed && typeof parsed === 'object' && parsed.v === PACK_BLOB_VERSION
94
+ } catch {
95
+ return false
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Decode a v2 envelope. Throws if `bytes` is not a valid v2 envelope.
101
+ * Caller is expected to have run `isV2Envelope` first when handling
102
+ * legacy/v2 mixed input.
103
+ */
104
+ export function decodePackBlob(bytes: Uint8Array): PackBlob {
105
+ const text = new TextDecoder().decode(bytes)
106
+ const parsed = JSON.parse(text) as Partial<PackBlob>
107
+ if (parsed.v !== PACK_BLOB_VERSION) {
108
+ throw new Error(`pack-blob: expected v=${PACK_BLOB_VERSION}, got ${parsed.v}`)
109
+ }
110
+ if (typeof parsed.root !== 'string') {
111
+ throw new Error('pack-blob: missing root field')
112
+ }
113
+ const files: Record<string, string> = {}
114
+ for (const [name, content] of Object.entries(parsed.files ?? {})) {
115
+ if (typeof content !== 'string') continue
116
+ if (!SAFE_NAME.test(name)) continue
117
+ files[name] = content
118
+ }
119
+ return { v: PACK_BLOB_VERSION, root: parsed.root, files }
120
+ }
@@ -0,0 +1,110 @@
1
+ import { afterEach, beforeEach, expect, test } from 'bun:test'
2
+ import { mkdtempSync, rmSync } from 'node:fs'
3
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { decodePackBlob, encodePackBlob } from './pack-blob'
7
+ import { gatherAgentPack, gatherUserPack, writeAgentPack, writeUserPack } from './pack-gather'
8
+
9
+ let tmp: string
10
+
11
+ beforeEach(() => {
12
+ tmp = mkdtempSync(join(tmpdir(), 'pack-gather-'))
13
+ })
14
+
15
+ afterEach(() => {
16
+ rmSync(tmp, { recursive: true, force: true })
17
+ })
18
+
19
+ async function seed(rel: string, content: string): Promise<void> {
20
+ const full = join(tmp, rel)
21
+ await mkdir(join(full, '..'), { recursive: true })
22
+ await writeFile(full, content)
23
+ }
24
+
25
+ test('gatherAgentPack returns root + non-excluded files', async () => {
26
+ await seed('MEMORY.md', '# memory index')
27
+ await seed('agent/identity.md', 'identity body')
28
+ await seed('agent/persona.md', 'persona body')
29
+ await seed('agent/learned-foo.md', 'foo content')
30
+ await seed('agent/learned-bar.md', 'bar content')
31
+
32
+ const res = await gatherAgentPack(tmp)
33
+ expect(res.root).toBe('# memory index')
34
+ expect(res.files['learned-foo.md']).toBe('foo content')
35
+ expect(res.files['learned-bar.md']).toBe('bar content')
36
+ // Identity + persona excluded — they have their own slots
37
+ expect(res.files['identity.md']).toBeUndefined()
38
+ expect(res.files['persona.md']).toBeUndefined()
39
+ })
40
+
41
+ test('gatherUserPack returns profile.md as root + other user files', async () => {
42
+ await seed('user/profile.md', '# user profile')
43
+ await seed('user/operator-preferences.md', 'dark mode')
44
+ await seed('user/0g-hackathon.md', 'deadline')
45
+ const res = await gatherUserPack(tmp)
46
+ expect(res.root).toBe('# user profile')
47
+ expect(res.files['operator-preferences.md']).toBe('dark mode')
48
+ expect(res.files['0g-hackathon.md']).toBe('deadline')
49
+ // profile.md is the root, not in files
50
+ expect(res.files['profile.md']).toBeUndefined()
51
+ })
52
+
53
+ test('gather handles missing partition dir', async () => {
54
+ await seed('MEMORY.md', 'just an index')
55
+ const res = await gatherAgentPack(tmp)
56
+ expect(res.root).toBe('just an index')
57
+ expect(res.files).toEqual({})
58
+ })
59
+
60
+ test('gather handles missing root file', async () => {
61
+ await seed('agent/learned-x.md', 'content')
62
+ const res = await gatherAgentPack(tmp)
63
+ expect(res.root).toBe('')
64
+ expect(res.files['learned-x.md']).toBe('content')
65
+ })
66
+
67
+ test('gather skips non-md files + empty .md', async () => {
68
+ await seed('agent/learned-x.md', 'content')
69
+ await seed('agent/junk.txt', 'not md')
70
+ await seed('agent/empty.md', '')
71
+ const res = await gatherAgentPack(tmp)
72
+ expect(res.files['learned-x.md']).toBe('content')
73
+ expect(res.files['junk.txt']).toBeUndefined()
74
+ expect(res.files['empty.md']).toBeUndefined()
75
+ })
76
+
77
+ test('writeAgentPack reverses gatherAgentPack', async () => {
78
+ await seed('MEMORY.md', 'index v1')
79
+ await seed('agent/learned-a.md', 'a')
80
+ await seed('agent/learned-b.md', 'b')
81
+ const gathered = await gatherAgentPack(tmp)
82
+ const bytes = encodePackBlob(gathered)
83
+ const decoded = decodePackBlob(bytes)
84
+
85
+ const dst = mkdtempSync(join(tmpdir(), 'pack-write-'))
86
+ try {
87
+ await writeAgentPack(dst, decoded)
88
+ expect(await readFile(join(dst, 'MEMORY.md'), 'utf8')).toBe('index v1')
89
+ expect(await readFile(join(dst, 'agent', 'learned-a.md'), 'utf8')).toBe('a')
90
+ expect(await readFile(join(dst, 'agent', 'learned-b.md'), 'utf8')).toBe('b')
91
+ } finally {
92
+ rmSync(dst, { recursive: true, force: true })
93
+ }
94
+ })
95
+
96
+ test('writeUserPack creates user dir + profile + sibling files', async () => {
97
+ const dst = mkdtempSync(join(tmpdir(), 'pack-write-'))
98
+ try {
99
+ await writeUserPack(dst, {
100
+ v: 2,
101
+ root: '# profile',
102
+ files: { 'operator-preferences.md': 'prefs', 'hack-2026.md': 'deadline' },
103
+ })
104
+ expect(await readFile(join(dst, 'user', 'profile.md'), 'utf8')).toBe('# profile')
105
+ expect(await readFile(join(dst, 'user', 'operator-preferences.md'), 'utf8')).toBe('prefs')
106
+ expect(await readFile(join(dst, 'user', 'hack-2026.md'), 'utf8')).toBe('deadline')
107
+ } finally {
108
+ rmSync(dst, { recursive: true, force: true })
109
+ }
110
+ })