nebula-ai-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 (109) hide show
  1. package/README.md +24 -0
  2. package/package.json +69 -0
  3. package/src/brain/compaction.ts +131 -0
  4. package/src/brain/frozen-prefix.ts +320 -0
  5. package/src/brain/history-persist.ts +154 -0
  6. package/src/brain/index.ts +43 -0
  7. package/src/brain/openai-brain.ts +533 -0
  8. package/src/brain/sanitize.ts +23 -0
  9. package/src/brain/stub.ts +20 -0
  10. package/src/brain/types.ts +129 -0
  11. package/src/chain.ts +75 -0
  12. package/src/claude-plugins/discovery.ts +152 -0
  13. package/src/claude-plugins/index.ts +6 -0
  14. package/src/claude-plugins/types.ts +38 -0
  15. package/src/commands/index.ts +16 -0
  16. package/src/commands/registry.ts +255 -0
  17. package/src/config.ts +213 -0
  18. package/src/economy/index.ts +6 -0
  19. package/src/events/index.ts +4 -0
  20. package/src/events/listeners.ts +37 -0
  21. package/src/events/queue.ts +63 -0
  22. package/src/events/router.ts +42 -0
  23. package/src/events/types.ts +28 -0
  24. package/src/format.ts +12 -0
  25. package/src/identity/agent-card.ts +110 -0
  26. package/src/identity/deployments.ts +20 -0
  27. package/src/identity/erc8004.ts +161 -0
  28. package/src/identity/index.ts +29 -0
  29. package/src/identity/keystore-blob.ts +60 -0
  30. package/src/identity/receipt.ts +27 -0
  31. package/src/identity/stub.ts +29 -0
  32. package/src/identity/types.ts +20 -0
  33. package/src/index.ts +372 -0
  34. package/src/locks.ts +233 -0
  35. package/src/mcp/discovery.ts +150 -0
  36. package/src/mcp/index.ts +10 -0
  37. package/src/mcp/manager.ts +110 -0
  38. package/src/mcp/stdio-client.ts +154 -0
  39. package/src/mcp/types.ts +44 -0
  40. package/src/memory/edit.ts +53 -0
  41. package/src/memory/encryption.ts +88 -0
  42. package/src/memory/fs-util.ts +15 -0
  43. package/src/memory/index-file.ts +74 -0
  44. package/src/memory/index-sync.ts +99 -0
  45. package/src/memory/index.ts +58 -0
  46. package/src/memory/list-tool.ts +105 -0
  47. package/src/memory/pack-blob.ts +120 -0
  48. package/src/memory/pack-gather.ts +112 -0
  49. package/src/memory/parser.ts +20 -0
  50. package/src/memory/read-tool.ts +198 -0
  51. package/src/memory/save-tool.ts +189 -0
  52. package/src/memory/scan.ts +63 -0
  53. package/src/memory/topic.ts +32 -0
  54. package/src/memory/types.ts +49 -0
  55. package/src/migration/index.ts +6 -0
  56. package/src/migration/option3-crypto.ts +127 -0
  57. package/src/operator/index.ts +9 -0
  58. package/src/operator/keychain.ts +53 -0
  59. package/src/operator/keystore-file.ts +33 -0
  60. package/src/operator/privkey-base.ts +60 -0
  61. package/src/operator/raw-privkey.ts +39 -0
  62. package/src/operator/signer.ts +46 -0
  63. package/src/operator/walletconnect.ts +454 -0
  64. package/src/pairing.ts +285 -0
  65. package/src/paths.ts +70 -0
  66. package/src/permission/dangerous.ts +108 -0
  67. package/src/permission/env-redact.ts +54 -0
  68. package/src/permission/index.ts +16 -0
  69. package/src/permission/path-guard.ts +114 -0
  70. package/src/permission/service.ts +191 -0
  71. package/src/plugins/context.ts +225 -0
  72. package/src/plugins/hooks.ts +81 -0
  73. package/src/plugins/index.ts +24 -0
  74. package/src/plugins/tool-search.ts +49 -0
  75. package/src/public/card.ts +67 -0
  76. package/src/runtime/activity.ts +29 -0
  77. package/src/runtime/index.ts +2 -0
  78. package/src/runtime/runtime.ts +113 -0
  79. package/src/sandbox/credentials.ts +25 -0
  80. package/src/sandbox/docker.ts +396 -0
  81. package/src/sandbox/factory.ts +99 -0
  82. package/src/sandbox/index.ts +15 -0
  83. package/src/sandbox/linux.ts +141 -0
  84. package/src/sandbox/local.ts +19 -0
  85. package/src/sandbox/macos.ts +71 -0
  86. package/src/sandbox/seatbelt-profile.ts +139 -0
  87. package/src/sandbox/types.ts +129 -0
  88. package/src/skills/index.ts +8 -0
  89. package/src/skills/scanner.ts +257 -0
  90. package/src/skills/triggers.ts +78 -0
  91. package/src/skills/types.ts +37 -0
  92. package/src/storage/encryption.ts +87 -0
  93. package/src/storage/factory.ts +31 -0
  94. package/src/storage/index.ts +11 -0
  95. package/src/storage/local-stub.ts +70 -0
  96. package/src/storage/sqlite.ts +95 -0
  97. package/src/storage/types.ts +21 -0
  98. package/src/tools/escalation.ts +200 -0
  99. package/src/tools/index.ts +11 -0
  100. package/src/tools/registry.ts +152 -0
  101. package/src/tools/types.ts +65 -0
  102. package/src/tools/zod-helpers.ts +36 -0
  103. package/src/tools/zod-schema.ts +99 -0
  104. package/src/wallet/drain.ts +79 -0
  105. package/src/wallet/eoa.ts +51 -0
  106. package/src/wallet/index.ts +47 -0
  107. package/src/wallet/keystore.ts +50 -0
  108. package/src/wallet/operator-keystore-crypto.ts +530 -0
  109. package/src/wallet/operator-session.ts +344 -0
@@ -0,0 +1,257 @@
1
+ import type { Dirent } from 'node:fs'
2
+ import { readFile, readdir, stat } from 'node:fs/promises'
3
+ import { homedir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { agentPaths } from '../paths'
6
+ import type { SkillFrontmatter, SkillRef, SkillSource } from './types'
7
+
8
+ /**
9
+ * Scan the canonical skill locations and return parsed SkillRefs. Skips paths
10
+ * that don't exist or aren't directories so callers can register all sources
11
+ * eagerly without needing to probe individually.
12
+ */
13
+ export interface SkillScannerOptions {
14
+ /** Whether to scan ~/.claude/skills/ + ~/.claude/plugins/cache/. Default true. */
15
+ importsClaudeCode?: boolean
16
+ /** Override for ~/.nebula/skills/ (test seam). Defaults to agentPaths.skills. */
17
+ nebulaSkillsRoot?: string
18
+ /** Override for ~/.nebula/plugins/ (test seam). Defaults to agentPaths.plugins. */
19
+ nebulaPluginsRoot?: string
20
+ /** Override for ~/.claude/skills/ (test seam). Defaults to ~/.claude/skills. */
21
+ claudeSkillsRoot?: string
22
+ /** Override for ~/.claude/plugins/cache/ (test seam). Defaults to ~/.claude/plugins/cache. */
23
+ claudePluginsCacheRoot?: string
24
+ }
25
+
26
+ export async function scanSkills(opts: SkillScannerOptions = {}): Promise<SkillRef[]> {
27
+ const importsClaudeCode = opts.importsClaudeCode ?? true
28
+ const nebulaSkillsRoot = opts.nebulaSkillsRoot ?? agentPaths.skills
29
+ const nebulaPluginsRoot = opts.nebulaPluginsRoot ?? agentPaths.plugins
30
+ const claudeSkillsRoot = opts.claudeSkillsRoot ?? join(homedir(), '.claude', 'skills')
31
+ const claudePluginsCacheRoot =
32
+ opts.claudePluginsCacheRoot ?? join(homedir(), '.claude', 'plugins', 'cache')
33
+
34
+ const refs: SkillRef[] = []
35
+ await collectSimple(nebulaSkillsRoot, 'nebula', refs)
36
+ await collectNebulaPluginSkills(nebulaPluginsRoot, refs)
37
+ if (importsClaudeCode) {
38
+ await collectSimple(claudeSkillsRoot, 'claude-code', refs)
39
+ await collectClaudePluginCacheSkills(claudePluginsCacheRoot, refs)
40
+ }
41
+ return refs
42
+ }
43
+
44
+ async function dirEntries(path: string): Promise<Dirent[] | null> {
45
+ try {
46
+ const s = await stat(path)
47
+ if (!s.isDirectory()) return null
48
+ } catch {
49
+ return null
50
+ }
51
+ try {
52
+ return (await readdir(path, { withFileTypes: true })) as Dirent[]
53
+ } catch {
54
+ return null
55
+ }
56
+ }
57
+
58
+ async function fileExists(path: string): Promise<boolean> {
59
+ try {
60
+ const s = await stat(path)
61
+ return s.isFile()
62
+ } catch {
63
+ return false
64
+ }
65
+ }
66
+
67
+ async function collectSimple(
68
+ root: string,
69
+ source: Extract<SkillSource, 'nebula' | 'claude-code'>,
70
+ out: SkillRef[],
71
+ ): Promise<void> {
72
+ const entries = await dirEntries(root)
73
+ if (!entries) return
74
+ for (const entry of entries) {
75
+ if (!entry.isDirectory()) continue
76
+ const skillPath = join(root, entry.name, 'SKILL.md')
77
+ if (!(await fileExists(skillPath))) continue
78
+ const ref = await loadSkill(skillPath, entry.name, source)
79
+ if (ref) out.push(ref)
80
+ }
81
+ }
82
+
83
+ async function collectNebulaPluginSkills(pluginsRoot: string, out: SkillRef[]): Promise<void> {
84
+ const plugins = await dirEntries(pluginsRoot)
85
+ if (!plugins) return
86
+ for (const plugin of plugins) {
87
+ if (!plugin.isDirectory()) continue
88
+ const skillsRoot = join(pluginsRoot, plugin.name, 'skills')
89
+ const skills = await dirEntries(skillsRoot)
90
+ if (!skills) continue
91
+ for (const skill of skills) {
92
+ if (!skill.isDirectory()) continue
93
+ const skillPath = join(skillsRoot, skill.name, 'SKILL.md')
94
+ if (!(await fileExists(skillPath))) continue
95
+ const ref = await loadSkill(skillPath, `${plugin.name}:${skill.name}`, 'nebula-plugin')
96
+ if (ref) out.push(ref)
97
+ }
98
+ }
99
+ }
100
+
101
+ async function collectClaudePluginCacheSkills(cacheRoot: string, out: SkillRef[]): Promise<void> {
102
+ const marketplaces = await dirEntries(cacheRoot)
103
+ if (!marketplaces) return
104
+ for (const marketplace of marketplaces) {
105
+ if (!marketplace.isDirectory()) continue
106
+ const marketDir = join(cacheRoot, marketplace.name)
107
+ const plugins = await dirEntries(marketDir)
108
+ if (!plugins) continue
109
+ for (const plugin of plugins) {
110
+ if (!plugin.isDirectory()) continue
111
+ // Layer 1: <market>/<plugin>/<version>/skills/<skill>/SKILL.md
112
+ // Layer 2: <market>/<plugin>/skills/<skill>/SKILL.md (no version dir)
113
+ // Try the version layer first; fall back to direct.
114
+ const versions = await dirEntries(join(marketDir, plugin.name))
115
+ if (!versions) continue
116
+ for (const versionEntry of versions) {
117
+ if (!versionEntry.isDirectory()) continue
118
+ const versionDir = join(marketDir, plugin.name, versionEntry.name)
119
+ // Two valid shapes; both checked.
120
+ await collectClaudeSkillsFromVersion(
121
+ versionDir,
122
+ marketplace.name,
123
+ plugin.name,
124
+ versionEntry.name,
125
+ out,
126
+ )
127
+ }
128
+ }
129
+ }
130
+ }
131
+
132
+ async function collectClaudeSkillsFromVersion(
133
+ versionDir: string,
134
+ marketplace: string,
135
+ plugin: string,
136
+ version: string,
137
+ out: SkillRef[],
138
+ ): Promise<void> {
139
+ const skillsDir = join(versionDir, 'skills')
140
+ const direct = await fileExists(join(versionDir, 'SKILL.md'))
141
+ if (direct) {
142
+ const ref = await loadSkill(
143
+ join(versionDir, 'SKILL.md'),
144
+ `${marketplace}:${plugin}`,
145
+ 'claude-plugin',
146
+ )
147
+ if (ref) {
148
+ ref.pluginCoord = { marketplace, plugin, version }
149
+ out.push(ref)
150
+ }
151
+ }
152
+ const skills = await dirEntries(skillsDir)
153
+ if (!skills) return
154
+ for (const skill of skills) {
155
+ if (!skill.isDirectory()) continue
156
+ const skillPath = join(skillsDir, skill.name, 'SKILL.md')
157
+ if (!(await fileExists(skillPath))) continue
158
+ const id = `${marketplace}:${plugin}:${skill.name}`
159
+ const ref = await loadSkill(skillPath, id, 'claude-plugin')
160
+ if (ref) {
161
+ ref.pluginCoord = { marketplace, plugin, version }
162
+ out.push(ref)
163
+ }
164
+ }
165
+ }
166
+
167
+ async function loadSkill(
168
+ path: string,
169
+ fallbackId: string,
170
+ source: SkillSource,
171
+ ): Promise<SkillRef | null> {
172
+ let raw: string
173
+ try {
174
+ raw = await readFile(path, 'utf8')
175
+ } catch {
176
+ return null
177
+ }
178
+ const fm = parseFrontmatter(raw)
179
+ // Skills without YAML frontmatter are still surfaced; we derive name from
180
+ // the directory and description from the first heading or paragraph so the
181
+ // brain can find them via `skills.list`.
182
+ const name = fm.name ?? fallbackId
183
+ const description = fm.description ?? deriveDescription(raw)
184
+ return {
185
+ id: `${source}:${fallbackId}`,
186
+ name,
187
+ description,
188
+ path,
189
+ source,
190
+ frontmatter: { name, description, ...fm },
191
+ }
192
+ }
193
+
194
+ function deriveDescription(raw: string): string {
195
+ const body = raw.startsWith('---') ? raw.slice(raw.indexOf('\n---', 4) + 4) : raw
196
+ for (const line of body.split('\n')) {
197
+ const trimmed = line.trim()
198
+ if (!trimmed) continue
199
+ if (trimmed.startsWith('#')) continue
200
+ return trimmed.slice(0, 200)
201
+ }
202
+ return ''
203
+ }
204
+
205
+ const KEY_RE = /^([a-zA-Z][a-zA-Z0-9_-]*):\s*(.*)$/
206
+
207
+ /**
208
+ * Minimal YAML frontmatter parser (top-level + one nested level for `metadata:`).
209
+ * We avoid pulling in a full YAML lib because skills only need a tiny subset and
210
+ * scan time runs on every chat boot.
211
+ */
212
+ export function parseFrontmatter(raw: string): Partial<SkillFrontmatter> {
213
+ if (!raw.startsWith('---')) return {}
214
+ const end = raw.indexOf('\n---', 4)
215
+ if (end === -1) return {}
216
+ const block = raw.slice(4, end)
217
+ const out: Partial<SkillFrontmatter> = {}
218
+ let inMetadata = false
219
+ for (const rawLine of block.split('\n')) {
220
+ if (rawLine.trim() === '') {
221
+ inMetadata = false
222
+ continue
223
+ }
224
+ const indented = rawLine.startsWith(' ') || rawLine.startsWith('\t')
225
+ const trimmed = rawLine.trim()
226
+ if (!indented) {
227
+ inMetadata = false
228
+ const m = trimmed.match(KEY_RE)
229
+ if (!m?.[1]) continue
230
+ const key = m[1]
231
+ const value = unquote(m[2] ?? '')
232
+ if (key === 'name') out.name = value
233
+ else if (key === 'description') out.description = value
234
+ else if (key === 'version') out.version = value
235
+ else if (key === 'license') out.license = value
236
+ else if (key === 'argument-hint' || key === 'argumentHint') out.argumentHint = value
237
+ else if (key === 'metadata') inMetadata = true
238
+ continue
239
+ }
240
+ if (!inMetadata) continue
241
+ const m = trimmed.match(KEY_RE)
242
+ if (!m?.[1]) continue
243
+ const key = m[1]
244
+ const value = unquote(m[2] ?? '')
245
+ if (key === 'filePattern') out.filePattern = value
246
+ else if (key === 'bashPattern') out.bashPattern = value
247
+ }
248
+ return out
249
+ }
250
+
251
+ function unquote(s: string): string {
252
+ const t = s.trim()
253
+ if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
254
+ return t.slice(1, -1)
255
+ }
256
+ return t
257
+ }
@@ -0,0 +1,78 @@
1
+ import type { SkillRef } from './types'
2
+
3
+ /**
4
+ * Match a comma-separated glob list (`*.test.ts,*.spec.ts`) against an
5
+ * absolute path. Globs are matched against the basename and against the
6
+ * trailing path segment (e.g. `tests/foo.spec.ts` matches `tests/*.spec.ts`).
7
+ */
8
+ export function matchFilePattern(pattern: string, absPath: string): boolean {
9
+ const globs = pattern
10
+ .split(',')
11
+ .map(s => s.trim())
12
+ .filter(Boolean)
13
+ if (globs.length === 0) return false
14
+ const basename = absPath.split('/').pop() ?? absPath
15
+ for (const g of globs) {
16
+ if (globToRegex(g).test(basename) || globToRegex(g).test(absPath)) return true
17
+ }
18
+ return false
19
+ }
20
+
21
+ export function matchBashPattern(pattern: string, command: string): boolean {
22
+ try {
23
+ return new RegExp(pattern).test(command)
24
+ } catch {
25
+ return false
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Find skills whose triggers match the given tool call. Returns the matched
31
+ * skills paired with the reason (so the brain sees "auto-loaded by file
32
+ * pattern" or "auto-loaded by bash pattern" in the injected context).
33
+ */
34
+ export interface SkillTriggerMatch {
35
+ skill: SkillRef
36
+ reason: 'filePattern' | 'bashPattern'
37
+ }
38
+
39
+ export function matchTriggers(
40
+ call: { name: string; args: unknown },
41
+ skills: readonly SkillRef[],
42
+ ): SkillTriggerMatch[] {
43
+ const args = (call.args ?? {}) as { path?: unknown; command?: unknown }
44
+ const matches: SkillTriggerMatch[] = []
45
+ if (
46
+ typeof args.path === 'string' &&
47
+ (call.name === 'fs.read' || call.name === 'fs.write' || call.name === 'fs.patch')
48
+ ) {
49
+ for (const skill of skills) {
50
+ const fp = skill.frontmatter.filePattern
51
+ if (fp && matchFilePattern(fp, args.path)) {
52
+ matches.push({ skill, reason: 'filePattern' })
53
+ }
54
+ }
55
+ }
56
+ if (typeof args.command === 'string' && call.name === 'shell.run') {
57
+ for (const skill of skills) {
58
+ const bp = skill.frontmatter.bashPattern
59
+ if (bp && matchBashPattern(bp, args.command)) {
60
+ matches.push({ skill, reason: 'bashPattern' })
61
+ }
62
+ }
63
+ }
64
+ return matches
65
+ }
66
+
67
+ function globToRegex(glob: string): RegExp {
68
+ let pat = ''
69
+ for (let i = 0; i < glob.length; i++) {
70
+ const c = glob[i]
71
+ if (c === '*') pat += '.*'
72
+ else if (c === '?') pat += '.'
73
+ else if (c === '.') pat += '\\.'
74
+ else if (c === '/') pat += '/'
75
+ else pat += c
76
+ }
77
+ return new RegExp(`^${pat}$`)
78
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Phase 9.1 skills surface. Mirrors Claude Code's SKILL.md frontmatter so
3
+ * imports.claudeCode picks up the entire ~/.claude ecosystem free.
4
+ */
5
+ export type SkillSource = 'nebula' | 'nebula-plugin' | 'claude-code' | 'claude-plugin'
6
+
7
+ export interface SkillFrontmatter {
8
+ /** Unique name used by the brain to reference this skill. Required. */
9
+ name: string
10
+ /** One-line summary the brain sees in the skill index. Required. */
11
+ description: string
12
+ version?: string
13
+ license?: string
14
+ /** Comma-separated globs (e.g. `*.test.ts,*.spec.ts`) that auto-trigger the skill on fs.* paths. */
15
+ filePattern?: string
16
+ /** Regex (string) that auto-triggers the skill on shell.run commands. */
17
+ bashPattern?: string
18
+ /**
19
+ * Claude Code commands set this to distinguish slash-only invocations from
20
+ * model-invokable skills. Skills omit it; commands set it (any value).
21
+ */
22
+ argumentHint?: string
23
+ }
24
+
25
+ export interface SkillRef {
26
+ /** `<source-prefix>:<dir-name>` (e.g. `nebula:dogfood`, `claude-code:tmux`). */
27
+ id: string
28
+ /** Display name from frontmatter (falls back to directory name). */
29
+ name: string
30
+ description: string
31
+ /** Absolute path to SKILL.md. */
32
+ path: string
33
+ source: SkillSource
34
+ /** When set, marketplace > plugin > version triple from `~/.claude/plugins/cache/...` paths. */
35
+ pluginCoord?: { marketplace: string; plugin: string; version: string }
36
+ frontmatter: SkillFrontmatter
37
+ }
@@ -0,0 +1,87 @@
1
+ import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'node:crypto'
2
+
3
+ const SCRYPT_N = 1 << 15
4
+ const SCRYPT_R = 8
5
+ const SCRYPT_P = 1
6
+ const SCRYPT_MAXMEM = 64 * 1024 * 1024
7
+ const KEY_LEN = 32
8
+ const IV_LEN = 12
9
+ const TAG_LEN = 16
10
+ const SALT_LEN = 16
11
+
12
+ /**
13
+ * AES-256-GCM symmetric encryption, scrypt-derived key from a passphrase.
14
+ * MVP pattern: each agent has one symmetric key derived from the operator
15
+ * passphrase. Same scrypt parameters as the wallet keystore for consistency.
16
+ *
17
+ * Post-MVP: replace with TEE-sealed key for /agent/ partition + ECIES to
18
+ * operator pubkey for /user/ partition (section 22 wallet architecture).
19
+ */
20
+
21
+ export interface EncryptedEnvelope {
22
+ /** Random 16-byte salt used to derive the symmetric key. */
23
+ salt: Uint8Array
24
+ /** Random 12-byte GCM IV. */
25
+ iv: Uint8Array
26
+ /** 16-byte GCM auth tag. */
27
+ tag: Uint8Array
28
+ /** Ciphertext. */
29
+ ciphertext: Uint8Array
30
+ }
31
+
32
+ export function encrypt(plaintext: Uint8Array, passphrase: string): EncryptedEnvelope {
33
+ const salt = randomBytes(SALT_LEN)
34
+ const iv = randomBytes(IV_LEN)
35
+ const key = scryptSync(passphrase, salt, KEY_LEN, {
36
+ N: SCRYPT_N,
37
+ r: SCRYPT_R,
38
+ p: SCRYPT_P,
39
+ maxmem: SCRYPT_MAXMEM,
40
+ })
41
+ const cipher = createCipheriv('aes-256-gcm', key, iv)
42
+ const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()])
43
+ const tag = cipher.getAuthTag()
44
+ return {
45
+ salt,
46
+ iv,
47
+ tag: new Uint8Array(tag),
48
+ ciphertext: new Uint8Array(ciphertext),
49
+ }
50
+ }
51
+
52
+ export function decrypt(envelope: EncryptedEnvelope, passphrase: string): Uint8Array {
53
+ const key = scryptSync(passphrase, envelope.salt, KEY_LEN, {
54
+ N: SCRYPT_N,
55
+ r: SCRYPT_R,
56
+ p: SCRYPT_P,
57
+ maxmem: SCRYPT_MAXMEM,
58
+ })
59
+ const decipher = createDecipheriv('aes-256-gcm', key, envelope.iv)
60
+ decipher.setAuthTag(envelope.tag)
61
+ const plaintext = Buffer.concat([decipher.update(envelope.ciphertext), decipher.final()])
62
+ return new Uint8Array(plaintext)
63
+ }
64
+
65
+ /** Pack envelope into a single byte buffer for storage: salt || iv || tag || ciphertext. */
66
+ export function packEnvelope(envelope: EncryptedEnvelope): Uint8Array {
67
+ const total = SALT_LEN + IV_LEN + TAG_LEN + envelope.ciphertext.length
68
+ const out = new Uint8Array(total)
69
+ out.set(envelope.salt, 0)
70
+ out.set(envelope.iv, SALT_LEN)
71
+ out.set(envelope.tag, SALT_LEN + IV_LEN)
72
+ out.set(envelope.ciphertext, SALT_LEN + IV_LEN + TAG_LEN)
73
+ return out
74
+ }
75
+
76
+ /** Unpack a packed envelope back into its fields. */
77
+ export function unpackEnvelope(packed: Uint8Array): EncryptedEnvelope {
78
+ if (packed.length < SALT_LEN + IV_LEN + TAG_LEN) {
79
+ throw new Error('envelope shorter than header')
80
+ }
81
+ return {
82
+ salt: packed.slice(0, SALT_LEN),
83
+ iv: packed.slice(SALT_LEN, SALT_LEN + IV_LEN),
84
+ tag: packed.slice(SALT_LEN + IV_LEN, SALT_LEN + IV_LEN + TAG_LEN),
85
+ ciphertext: packed.slice(SALT_LEN + IV_LEN + TAG_LEN),
86
+ }
87
+ }
@@ -0,0 +1,31 @@
1
+ import { join } from 'node:path'
2
+ import { agentPaths } from '../paths'
3
+ import { SqliteStorage } from './sqlite'
4
+ import type { Storage } from './types'
5
+
6
+ let singleton: SqliteStorage | null = null
7
+
8
+ /**
9
+ * Shared, content-addressed local store at `~/.nebula/storage.sqlite`.
10
+ * Blobs are addressed by their `0x`+sha256 CID, so a single store serves all
11
+ * agents (a blob put by one is fetchable by hash from any). KV/log entries are
12
+ * namespaced by streamId. Replaces the prior decentralized storage backend.
13
+ */
14
+ export function getStorage(): Storage {
15
+ if (!singleton) {
16
+ singleton = new SqliteStorage(join(agentPaths.root, 'storage.sqlite'))
17
+ }
18
+ return singleton
19
+ }
20
+
21
+ /**
22
+ * Back-compat shim for the old "download blob by on-chain root hash" call.
23
+ * Blobs are content-addressed (rootHash === CID), so this is just `getBlob`.
24
+ * The network arg is ignored; kept so existing call sites need no reshaping.
25
+ */
26
+ export async function downloadBlobByRoot(
27
+ _network: unknown,
28
+ rootHash: string,
29
+ ): Promise<Uint8Array | null> {
30
+ return getStorage().getBlob(rootHash)
31
+ }
@@ -0,0 +1,11 @@
1
+ export type { Storage } from './types'
2
+ export { LocalStubStorage } from './local-stub'
3
+ export { SqliteStorage } from './sqlite'
4
+ export { getStorage, downloadBlobByRoot } from './factory'
5
+ export {
6
+ encrypt,
7
+ decrypt,
8
+ packEnvelope,
9
+ unpackEnvelope,
10
+ type EncryptedEnvelope,
11
+ } from './encryption'
@@ -0,0 +1,70 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises'
3
+ import { join } from 'node:path'
4
+ import type { Storage } from './types'
5
+
6
+ /**
7
+ * Local-disk Storage stub. Layout under `${root}/storage-stub`:
8
+ * kv/<streamId>/<url-encoded-key> — latest value
9
+ * log/<streamId>.jsonl — append-only JSONL
10
+ * blob/<cid> — immutable by content hash
11
+ */
12
+ export class LocalStubStorage implements Storage {
13
+ constructor(private readonly root: string) {}
14
+
15
+ private kvPath(stream: string, key: string): string {
16
+ return join(this.root, 'storage-stub', 'kv', stream, encodeURIComponent(key))
17
+ }
18
+
19
+ private logPath(stream: string): string {
20
+ return join(this.root, 'storage-stub', 'log', `${stream}.jsonl`)
21
+ }
22
+
23
+ private blobPath(cid: string): string {
24
+ return join(this.root, 'storage-stub', 'blob', cid)
25
+ }
26
+
27
+ async putKV(stream: string, key: string, value: Uint8Array): Promise<void> {
28
+ const p = this.kvPath(stream, key)
29
+ await mkdir(join(p, '..'), { recursive: true })
30
+ await writeFile(p, value)
31
+ }
32
+
33
+ async getKV(stream: string, key: string): Promise<Uint8Array | null> {
34
+ return await readOrNull(this.kvPath(stream, key))
35
+ }
36
+
37
+ async appendLog(stream: string, entry: Uint8Array): Promise<string> {
38
+ const p = this.logPath(stream)
39
+ await mkdir(join(p, '..'), { recursive: true })
40
+ const cid = cidOf(entry)
41
+ const line = JSON.stringify({ cid, hex: Buffer.from(entry).toString('hex'), ts: Date.now() })
42
+ await appendFile(p, `${line}\n`)
43
+ return cid
44
+ }
45
+
46
+ async putBlob(bytes: Uint8Array): Promise<string> {
47
+ const cid = cidOf(bytes)
48
+ const p = this.blobPath(cid)
49
+ await mkdir(join(p, '..'), { recursive: true })
50
+ await writeFile(p, bytes)
51
+ return cid
52
+ }
53
+
54
+ async getBlob(cid: string): Promise<Uint8Array | null> {
55
+ return await readOrNull(this.blobPath(cid))
56
+ }
57
+ }
58
+
59
+ async function readOrNull(path: string): Promise<Uint8Array | null> {
60
+ try {
61
+ return new Uint8Array(await readFile(path))
62
+ } catch (e) {
63
+ if ((e as NodeJS.ErrnoException).code === 'ENOENT') return null
64
+ throw e
65
+ }
66
+ }
67
+
68
+ function cidOf(bytes: Uint8Array): string {
69
+ return `0x${createHash('sha256').update(bytes).digest('hex')}`
70
+ }
@@ -0,0 +1,95 @@
1
+ import { Database } from 'bun:sqlite'
2
+ import { createHash } from 'node:crypto'
3
+ import { mkdirSync } from 'node:fs'
4
+ import { dirname } from 'node:path'
5
+ import type { Storage } from './types'
6
+
7
+ /**
8
+ * SQLite-backed Storage (via `bun:sqlite`). Replaces the prior decentralized
9
+ * blob backend with a local, zero-infra store — ideal for the agent's
10
+ * encrypted memory and easy to demo. Implements the same three primitives:
11
+ * - KV: mutable value per (stream, key)
12
+ * - Log: append-only entries, each addressed by content CID
13
+ * - Blob: immutable, content-addressed bytes
14
+ *
15
+ * CID convention matches LocalStubStorage: `0x` + sha256(bytes) hex.
16
+ */
17
+ export class SqliteStorage implements Storage {
18
+ private readonly db: Database
19
+
20
+ /** @param path SQLite file path (defaults to in-memory). Parent dir is created. */
21
+ constructor(path = ':memory:') {
22
+ if (path !== ':memory:') {
23
+ mkdirSync(dirname(path), { recursive: true })
24
+ }
25
+ this.db = new Database(path, { create: true })
26
+ this.db.run('PRAGMA journal_mode = WAL;')
27
+ this.db.run(
28
+ `CREATE TABLE IF NOT EXISTS kv (
29
+ stream TEXT NOT NULL,
30
+ key TEXT NOT NULL,
31
+ value BLOB NOT NULL,
32
+ PRIMARY KEY (stream, key)
33
+ );`,
34
+ )
35
+ this.db.run(
36
+ `CREATE TABLE IF NOT EXISTS log (
37
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
38
+ stream TEXT NOT NULL,
39
+ cid TEXT NOT NULL,
40
+ entry BLOB NOT NULL,
41
+ ts INTEGER NOT NULL
42
+ );`,
43
+ )
44
+ this.db.run('CREATE INDEX IF NOT EXISTS log_stream_idx ON log (stream, id);')
45
+ this.db.run(
46
+ `CREATE TABLE IF NOT EXISTS blob (
47
+ cid TEXT PRIMARY KEY,
48
+ bytes BLOB NOT NULL
49
+ );`,
50
+ )
51
+ }
52
+
53
+ async putKV(stream: string, key: string, value: Uint8Array): Promise<void> {
54
+ this.db
55
+ .query('INSERT OR REPLACE INTO kv (stream, key, value) VALUES (?, ?, ?)')
56
+ .run(stream, key, value)
57
+ }
58
+
59
+ async getKV(stream: string, key: string): Promise<Uint8Array | null> {
60
+ const row = this.db
61
+ .query('SELECT value FROM kv WHERE stream = ? AND key = ?')
62
+ .get(stream, key) as { value: Uint8Array } | null
63
+ return row ? new Uint8Array(row.value) : null
64
+ }
65
+
66
+ async appendLog(stream: string, entry: Uint8Array): Promise<string> {
67
+ const cid = cidOf(entry)
68
+ this.db
69
+ .query('INSERT INTO log (stream, cid, entry, ts) VALUES (?, ?, ?, ?)')
70
+ .run(stream, cid, entry, Date.now())
71
+ return cid
72
+ }
73
+
74
+ async putBlob(bytes: Uint8Array): Promise<string> {
75
+ const cid = cidOf(bytes)
76
+ this.db.query('INSERT OR IGNORE INTO blob (cid, bytes) VALUES (?, ?)').run(cid, bytes)
77
+ return cid
78
+ }
79
+
80
+ async getBlob(cid: string): Promise<Uint8Array | null> {
81
+ const row = this.db.query('SELECT bytes FROM blob WHERE cid = ?').get(cid) as {
82
+ bytes: Uint8Array
83
+ } | null
84
+ return row ? new Uint8Array(row.bytes) : null
85
+ }
86
+
87
+ /** Close the underlying database handle. */
88
+ close(): void {
89
+ this.db.close()
90
+ }
91
+ }
92
+
93
+ function cidOf(bytes: Uint8Array): string {
94
+ return `0x${createHash('sha256').update(bytes).digest('hex')}`
95
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Storage interface abstracting Mantle Storage's three primitives as used by nebula:
3
+ * - KV: mutable key→value per namespace
4
+ * - Log: append-only, returns CID per entry
5
+ * - Blob: immutable bytes, content-addressed
6
+ *
7
+ * Phase 1 ships a local-disk stub. Phase 5 ships the real @0gfoundation/0g-ts-sdk
8
+ * backend + on-chain-event replay for KV reads (per verified architecture).
9
+ */
10
+ export interface Storage {
11
+ /** Put a value into a named stream under a key. */
12
+ putKV(streamId: string, key: string, value: Uint8Array): Promise<void>
13
+ /** Get the latest value for (streamId, key) or null. */
14
+ getKV(streamId: string, key: string): Promise<Uint8Array | null>
15
+ /** Append an entry to a stream's log. Returns CID (rootHash) of the entry. */
16
+ appendLog(streamId: string, entry: Uint8Array): Promise<string>
17
+ /** Upload immutable bytes, returns content CID. */
18
+ putBlob(bytes: Uint8Array): Promise<string>
19
+ /** Retrieve bytes by CID. */
20
+ getBlob(cid: string): Promise<Uint8Array | null>
21
+ }