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.
- package/README.md +24 -0
- package/package.json +69 -0
- package/src/brain/compaction.ts +131 -0
- package/src/brain/frozen-prefix.ts +320 -0
- package/src/brain/history-persist.ts +154 -0
- package/src/brain/index.ts +43 -0
- package/src/brain/openai-brain.ts +533 -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 +75 -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.ts +255 -0
- package/src/config.ts +213 -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.ts +63 -0
- package/src/events/router.ts +42 -0
- package/src/events/types.ts +28 -0
- package/src/format.ts +12 -0
- package/src/identity/agent-card.ts +110 -0
- package/src/identity/deployments.ts +20 -0
- package/src/identity/erc8004.ts +161 -0
- package/src/identity/index.ts +29 -0
- package/src/identity/keystore-blob.ts +60 -0
- package/src/identity/receipt.ts +27 -0
- package/src/identity/stub.ts +29 -0
- package/src/identity/types.ts +20 -0
- package/src/index.ts +372 -0
- package/src/locks.ts +233 -0
- package/src/mcp/discovery.ts +150 -0
- package/src/mcp/index.ts +10 -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.ts +53 -0
- package/src/memory/encryption.ts +88 -0
- package/src/memory/fs-util.ts +15 -0
- package/src/memory/index-file.ts +74 -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.ts +120 -0
- package/src/memory/pack-gather.ts +112 -0
- package/src/memory/parser.ts +20 -0
- package/src/memory/read-tool.ts +198 -0
- package/src/memory/save-tool.ts +189 -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.ts +127 -0
- package/src/operator/index.ts +9 -0
- package/src/operator/keychain.ts +53 -0
- package/src/operator/keystore-file.ts +33 -0
- package/src/operator/privkey-base.ts +60 -0
- package/src/operator/raw-privkey.ts +39 -0
- package/src/operator/signer.ts +46 -0
- package/src/operator/walletconnect.ts +454 -0
- package/src/pairing.ts +285 -0
- package/src/paths.ts +70 -0
- package/src/permission/dangerous.ts +108 -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/service.ts +191 -0
- package/src/plugins/context.ts +225 -0
- package/src/plugins/hooks.ts +81 -0
- package/src/plugins/index.ts +24 -0
- package/src/plugins/tool-search.ts +49 -0
- package/src/public/card.ts +67 -0
- package/src/runtime/activity.ts +29 -0
- package/src/runtime/index.ts +2 -0
- package/src/runtime/runtime.ts +113 -0
- package/src/sandbox/credentials.ts +25 -0
- package/src/sandbox/docker.ts +396 -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/seatbelt-profile.ts +139 -0
- package/src/sandbox/types.ts +129 -0
- package/src/skills/index.ts +8 -0
- package/src/skills/scanner.ts +257 -0
- package/src/skills/triggers.ts +78 -0
- package/src/skills/types.ts +37 -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.ts +95 -0
- package/src/storage/types.ts +21 -0
- package/src/tools/escalation.ts +200 -0
- package/src/tools/index.ts +11 -0
- package/src/tools/registry.ts +152 -0
- package/src/tools/types.ts +65 -0
- package/src/tools/zod-helpers.ts +36 -0
- package/src/tools/zod-schema.ts +99 -0
- package/src/wallet/drain.ts +79 -0
- package/src/wallet/eoa.ts +51 -0
- package/src/wallet/index.ts +47 -0
- package/src/wallet/keystore.ts +50 -0
- package/src/wallet/operator-keystore-crypto.ts +530 -0
- 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
|
+
}
|