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
package/src/chain.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import {
|
|
2
|
+
http,
|
|
3
|
+
type Chain,
|
|
4
|
+
type Hex,
|
|
5
|
+
type PublicClient,
|
|
6
|
+
type WalletClient,
|
|
7
|
+
createPublicClient,
|
|
8
|
+
createWalletClient,
|
|
9
|
+
defineChain,
|
|
10
|
+
} from 'viem'
|
|
11
|
+
import { type PrivateKeyAccount, privateKeyToAccount } from 'viem/accounts'
|
|
12
|
+
import { NETWORK_CHAIN_ID, NETWORK_RPC, type NebulaNetwork } from './config'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Static fallback floor when `eth_gasPrice` is unreachable. 4 gwei matches the
|
|
16
|
+
* current Mantle mainnet floor (verified Apr 27 2026). Real callers should prefer
|
|
17
|
+
* `getGasPriceWithFloor` so the value tracks network conditions; this constant
|
|
18
|
+
* is the safety net.
|
|
19
|
+
*
|
|
20
|
+
* History: was 2.5 gwei; bumped to 4 gwei when txs began rejecting with
|
|
21
|
+
* "gas required exceeds allowance" (Geth's misleading wording for min-fee
|
|
22
|
+
* rejection, not OOG).
|
|
23
|
+
*/
|
|
24
|
+
export const MIN_GAS_PRICE = 4_000_000_000n
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Read the network's current `eth_gasPrice` and return `max(networkPrice, MIN_GAS_PRICE)`.
|
|
28
|
+
* Falls back to MIN_GAS_PRICE on RPC failure. Always returns a value safe to
|
|
29
|
+
* pass as `maxFeePerGas` / `maxPriorityFeePerGas` for an EIP-1559 tx; the
|
|
30
|
+
* floor protects against a momentarily-low quote, and using the live value
|
|
31
|
+
* means we don't underpay when the network's floor moves up.
|
|
32
|
+
*/
|
|
33
|
+
export async function getGasPriceWithFloor(client: PublicClient): Promise<bigint> {
|
|
34
|
+
try {
|
|
35
|
+
const price = await client.getGasPrice()
|
|
36
|
+
return price > MIN_GAS_PRICE ? price : MIN_GAS_PRICE
|
|
37
|
+
} catch {
|
|
38
|
+
return MIN_GAS_PRICE
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Empirical gas budget for `Mantle Storage Flow.submit()`. Used by preflight balance checks. */
|
|
43
|
+
export const STORAGE_SUBMIT_GAS = 250_000n
|
|
44
|
+
|
|
45
|
+
export function mantleChain(network: NebulaNetwork): Chain {
|
|
46
|
+
const isMainnet = network === 'mantle-mainnet'
|
|
47
|
+
return defineChain({
|
|
48
|
+
id: NETWORK_CHAIN_ID[network],
|
|
49
|
+
name: isMainnet ? 'Mantle' : 'Mantle Sepolia Testnet',
|
|
50
|
+
nativeCurrency: { name: 'Mantle', symbol: 'MNT', decimals: 18 },
|
|
51
|
+
rpcUrls: { default: { http: [NETWORK_RPC[network]] } },
|
|
52
|
+
blockExplorers: {
|
|
53
|
+
default: {
|
|
54
|
+
name: isMainnet ? 'MantleScan' : 'Mantle Sepolia Explorer',
|
|
55
|
+
url: isMainnet ? 'https://mantlescan.xyz' : 'https://sepolia.mantlescan.xyz',
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface ViemClients {
|
|
62
|
+
chain: Chain
|
|
63
|
+
account: PrivateKeyAccount
|
|
64
|
+
publicClient: PublicClient
|
|
65
|
+
walletClient: WalletClient
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function makeViemClients(opts: { network: NebulaNetwork; privkeyHex: Hex }): ViemClients {
|
|
69
|
+
const chain = mantleChain(opts.network)
|
|
70
|
+
const account = privateKeyToAccount(opts.privkeyHex)
|
|
71
|
+
const transport = http(NETWORK_RPC[opts.network])
|
|
72
|
+
const publicClient = createPublicClient({ transport, chain })
|
|
73
|
+
const walletClient = createWalletClient({ transport, account, chain })
|
|
74
|
+
return { chain, account, publicClient, walletClient }
|
|
75
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
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 type { ClaudeAgent, ClaudeCommand, ClaudeExtrasDiscoveryResult } from './types'
|
|
6
|
+
|
|
7
|
+
export interface ClaudeExtrasOptions {
|
|
8
|
+
importsClaudeCode?: boolean
|
|
9
|
+
/** Override for ~/.claude/plugins/cache/. */
|
|
10
|
+
claudePluginsCacheRoot?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function discoverClaudeExtras(
|
|
14
|
+
opts: ClaudeExtrasOptions = {},
|
|
15
|
+
): Promise<ClaudeExtrasDiscoveryResult> {
|
|
16
|
+
const importsClaudeCode = opts.importsClaudeCode ?? true
|
|
17
|
+
if (!importsClaudeCode) return { commands: [], agents: [] }
|
|
18
|
+
const cacheRoot = opts.claudePluginsCacheRoot ?? join(homedir(), '.claude', 'plugins', 'cache')
|
|
19
|
+
const commands: ClaudeCommand[] = []
|
|
20
|
+
const agents: ClaudeAgent[] = []
|
|
21
|
+
|
|
22
|
+
let marketplaces: Dirent[]
|
|
23
|
+
try {
|
|
24
|
+
const s = await stat(cacheRoot)
|
|
25
|
+
if (!s.isDirectory()) return { commands, agents }
|
|
26
|
+
marketplaces = (await readdir(cacheRoot, { withFileTypes: true })) as Dirent[]
|
|
27
|
+
} catch {
|
|
28
|
+
return { commands, agents }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
for (const market of marketplaces) {
|
|
32
|
+
if (!market.isDirectory()) continue
|
|
33
|
+
const marketDir = join(cacheRoot, market.name)
|
|
34
|
+
let plugins: Dirent[]
|
|
35
|
+
try {
|
|
36
|
+
plugins = (await readdir(marketDir, { withFileTypes: true })) as Dirent[]
|
|
37
|
+
} catch {
|
|
38
|
+
continue
|
|
39
|
+
}
|
|
40
|
+
for (const plugin of plugins) {
|
|
41
|
+
if (!plugin.isDirectory()) continue
|
|
42
|
+
const pluginDir = join(marketDir, plugin.name)
|
|
43
|
+
let versions: Dirent[]
|
|
44
|
+
try {
|
|
45
|
+
versions = (await readdir(pluginDir, { withFileTypes: true })) as Dirent[]
|
|
46
|
+
} catch {
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
const versionDirs = versions
|
|
50
|
+
.filter(v => v.isDirectory())
|
|
51
|
+
.map(v => v.name)
|
|
52
|
+
.sort()
|
|
53
|
+
const latest = versionDirs[versionDirs.length - 1]
|
|
54
|
+
if (!latest) continue
|
|
55
|
+
const versionDir = join(pluginDir, latest)
|
|
56
|
+
const source = { marketplace: market.name, plugin: plugin.name, version: latest }
|
|
57
|
+
await collectFromDir(join(versionDir, 'commands'), source, 'command', commands, agents)
|
|
58
|
+
await collectFromDir(join(versionDir, 'agents'), source, 'agent', commands, agents)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return { commands, agents }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function collectFromDir(
|
|
65
|
+
dir: string,
|
|
66
|
+
source: { marketplace: string; plugin: string; version: string },
|
|
67
|
+
kind: 'command' | 'agent',
|
|
68
|
+
commands: ClaudeCommand[],
|
|
69
|
+
agents: ClaudeAgent[],
|
|
70
|
+
): Promise<void> {
|
|
71
|
+
let entries: Dirent[]
|
|
72
|
+
try {
|
|
73
|
+
const s = await stat(dir)
|
|
74
|
+
if (!s.isDirectory()) return
|
|
75
|
+
entries = (await readdir(dir, { withFileTypes: true })) as Dirent[]
|
|
76
|
+
} catch {
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
for (const entry of entries) {
|
|
80
|
+
if (!entry.isFile() || !entry.name.endsWith('.md')) continue
|
|
81
|
+
const filePath = join(dir, entry.name)
|
|
82
|
+
let raw: string
|
|
83
|
+
try {
|
|
84
|
+
raw = await readFile(filePath, 'utf8')
|
|
85
|
+
} catch {
|
|
86
|
+
continue
|
|
87
|
+
}
|
|
88
|
+
const parsed = parseFile(raw)
|
|
89
|
+
if (!parsed) continue
|
|
90
|
+
const id = `${source.plugin}:${parsed.name ?? entry.name.replace(/\.md$/, '')}`
|
|
91
|
+
const name = parsed.name ?? entry.name.replace(/\.md$/, '')
|
|
92
|
+
if (kind === 'command') {
|
|
93
|
+
commands.push({
|
|
94
|
+
id,
|
|
95
|
+
name,
|
|
96
|
+
description: parsed.description ?? '',
|
|
97
|
+
argumentHint: parsed.argumentHint,
|
|
98
|
+
path: filePath,
|
|
99
|
+
body: parsed.body,
|
|
100
|
+
source,
|
|
101
|
+
})
|
|
102
|
+
} else {
|
|
103
|
+
agents.push({
|
|
104
|
+
id,
|
|
105
|
+
name,
|
|
106
|
+
description: parsed.description ?? '',
|
|
107
|
+
model: parsed.model,
|
|
108
|
+
path: filePath,
|
|
109
|
+
body: parsed.body,
|
|
110
|
+
source,
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
interface ParsedFile {
|
|
117
|
+
name?: string
|
|
118
|
+
description?: string
|
|
119
|
+
argumentHint?: string
|
|
120
|
+
model?: string
|
|
121
|
+
body: string
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function parseFile(raw: string): ParsedFile | null {
|
|
125
|
+
if (!raw.startsWith('---')) {
|
|
126
|
+
return { body: raw }
|
|
127
|
+
}
|
|
128
|
+
const end = raw.indexOf('\n---', 4)
|
|
129
|
+
if (end === -1) return { body: raw }
|
|
130
|
+
const block = raw.slice(4, end)
|
|
131
|
+
const body = raw.slice(end + 4).replace(/^\n/, '')
|
|
132
|
+
const out: ParsedFile = { body }
|
|
133
|
+
for (const line of block.split('\n')) {
|
|
134
|
+
const m = line.match(/^([a-zA-Z][a-zA-Z0-9_-]*):\s*(.*)$/)
|
|
135
|
+
if (!m?.[1]) continue
|
|
136
|
+
const key = m[1]
|
|
137
|
+
const value = unquote(m[2] ?? '')
|
|
138
|
+
if (key === 'name') out.name = value
|
|
139
|
+
else if (key === 'description') out.description = value
|
|
140
|
+
else if (key === 'argument-hint' || key === 'argumentHint') out.argumentHint = value
|
|
141
|
+
else if (key === 'model') out.model = value
|
|
142
|
+
}
|
|
143
|
+
return out
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function unquote(s: string): string {
|
|
147
|
+
const t = s.trim()
|
|
148
|
+
if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
|
|
149
|
+
return t.slice(1, -1)
|
|
150
|
+
}
|
|
151
|
+
return t
|
|
152
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 9.2 Bundle 8 surfaces: Claude Code commands + agents discovered from
|
|
3
|
+
* the local plugin cache. Both are markdown files with YAML frontmatter; the
|
|
4
|
+
* body is the prompt that nebula inlines when the command/agent fires.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface ClaudeCommand {
|
|
8
|
+
/** `<plugin>:<name>` (nebula drops the marketplace prefix; the cmd surface is flat). */
|
|
9
|
+
id: string
|
|
10
|
+
/** Bare command name (e.g. `setup`, `mode`, `commit`). */
|
|
11
|
+
name: string
|
|
12
|
+
description: string
|
|
13
|
+
/** Optional argument-hint shown after the slash command in help. */
|
|
14
|
+
argumentHint?: string
|
|
15
|
+
/** Absolute path to the markdown file. */
|
|
16
|
+
path: string
|
|
17
|
+
/** Body without frontmatter; this is the prompt template nebula inlines. */
|
|
18
|
+
body: string
|
|
19
|
+
/** Source plugin coordinates (marketplace, plugin, version). */
|
|
20
|
+
source: { marketplace: string; plugin: string; version: string }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ClaudeAgent {
|
|
24
|
+
/** `<plugin>:<name>` */
|
|
25
|
+
id: string
|
|
26
|
+
name: string
|
|
27
|
+
description: string
|
|
28
|
+
/** Optional model hint from frontmatter (e.g. `sonnet`). nebula ignores it (uses configured brain). */
|
|
29
|
+
model?: string
|
|
30
|
+
path: string
|
|
31
|
+
body: string
|
|
32
|
+
source: { marketplace: string; plugin: string; version: string }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ClaudeExtrasDiscoveryResult {
|
|
36
|
+
commands: ClaudeCommand[]
|
|
37
|
+
agents: ClaudeAgent[]
|
|
38
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export {
|
|
2
|
+
COMMAND_REGISTRY,
|
|
3
|
+
applyPerms,
|
|
4
|
+
applyYolo,
|
|
5
|
+
commandsForSurface,
|
|
6
|
+
findCommand,
|
|
7
|
+
parseSlash,
|
|
8
|
+
suggestForPrefix,
|
|
9
|
+
type ApplyResult,
|
|
10
|
+
type CommandScope,
|
|
11
|
+
type CommandSurface,
|
|
12
|
+
type ParsedSlash,
|
|
13
|
+
type PermissionApi,
|
|
14
|
+
type PermissionToggleMode,
|
|
15
|
+
type SlashCommand,
|
|
16
|
+
} from './registry'
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared slash-command registry. The TUI autocomplete popup, TG
|
|
3
|
+
* `setMyCommands` registration, and the various bypass dispatchers all read
|
|
4
|
+
* from this single source so command lists never drift between surfaces.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type CommandSurface = 'tui' | 'tg'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* - `local` : runs in the CLI process (TUI handler, local TG via chat-telegram)
|
|
11
|
+
* - `gateway`: runs in the gateway process (sandbox harness or local-mode gateway daemon)
|
|
12
|
+
* - `both` : valid in either surface; routing decided by where the command arrives
|
|
13
|
+
*/
|
|
14
|
+
export type CommandScope = 'local' | 'gateway' | 'both'
|
|
15
|
+
|
|
16
|
+
export interface SlashCommand {
|
|
17
|
+
/** Command name without leading slash, e.g. "yolo". Lowercased. */
|
|
18
|
+
name: string
|
|
19
|
+
/** One-line human description used in TUI menu + TG client menu. */
|
|
20
|
+
description: string
|
|
21
|
+
/** Surfaces the command should appear in for discovery. */
|
|
22
|
+
surfaces: CommandSurface[]
|
|
23
|
+
/** Where the command actually executes. */
|
|
24
|
+
scope: CommandScope
|
|
25
|
+
/** True when the command short-circuits before brain.infer (control commands). */
|
|
26
|
+
bypassesBrain: boolean
|
|
27
|
+
/** Optional argument hint shown next to the name in menus, e.g. "off|prompt|strict". */
|
|
28
|
+
argHint?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const COMMAND_REGISTRY: SlashCommand[] = [
|
|
32
|
+
{
|
|
33
|
+
name: 'yolo',
|
|
34
|
+
description: 'Toggle approval prompts on/off',
|
|
35
|
+
surfaces: ['tui', 'tg'],
|
|
36
|
+
scope: 'both',
|
|
37
|
+
bypassesBrain: true,
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: 'perms',
|
|
41
|
+
description: 'Set permission mode',
|
|
42
|
+
surfaces: ['tui', 'tg'],
|
|
43
|
+
scope: 'both',
|
|
44
|
+
bypassesBrain: true,
|
|
45
|
+
argHint: 'off|prompt|strict',
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: 'reset',
|
|
49
|
+
description: 'Clear conversation history for this channel',
|
|
50
|
+
surfaces: ['tui', 'tg'],
|
|
51
|
+
scope: 'both',
|
|
52
|
+
bypassesBrain: true,
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: 'sync',
|
|
56
|
+
description: 'Force memory sync to Mantle Storage',
|
|
57
|
+
surfaces: ['tui'],
|
|
58
|
+
scope: 'local',
|
|
59
|
+
bypassesBrain: true,
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
name: 'model',
|
|
63
|
+
description: 'Show brain model switch hint',
|
|
64
|
+
surfaces: ['tui'],
|
|
65
|
+
scope: 'local',
|
|
66
|
+
bypassesBrain: true,
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: 'jobs',
|
|
70
|
+
description: 'List active marketplace jobs',
|
|
71
|
+
surfaces: ['tui'],
|
|
72
|
+
scope: 'local',
|
|
73
|
+
bypassesBrain: true,
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
name: 'help',
|
|
77
|
+
description: 'Show all commands',
|
|
78
|
+
surfaces: ['tui'],
|
|
79
|
+
scope: 'local',
|
|
80
|
+
bypassesBrain: true,
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
name: 'exit',
|
|
84
|
+
description: 'Quit nebula',
|
|
85
|
+
surfaces: ['tui'],
|
|
86
|
+
scope: 'local',
|
|
87
|
+
bypassesBrain: true,
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
name: 'quit',
|
|
91
|
+
description: 'Quit nebula',
|
|
92
|
+
surfaces: ['tui'],
|
|
93
|
+
scope: 'local',
|
|
94
|
+
bypassesBrain: true,
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
name: 'stop',
|
|
98
|
+
description: 'Cancel current turn',
|
|
99
|
+
surfaces: ['tg'],
|
|
100
|
+
scope: 'gateway',
|
|
101
|
+
bypassesBrain: true,
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
name: 'new',
|
|
105
|
+
description: 'Start a fresh session',
|
|
106
|
+
surfaces: ['tg'],
|
|
107
|
+
scope: 'gateway',
|
|
108
|
+
bypassesBrain: true,
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
name: 'status',
|
|
112
|
+
description: 'Show agent status',
|
|
113
|
+
surfaces: ['tg'],
|
|
114
|
+
scope: 'gateway',
|
|
115
|
+
bypassesBrain: true,
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
name: 'approve',
|
|
119
|
+
description: 'Approve the pending request',
|
|
120
|
+
surfaces: ['tg'],
|
|
121
|
+
scope: 'gateway',
|
|
122
|
+
bypassesBrain: true,
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
name: 'deny',
|
|
126
|
+
description: 'Deny the pending request',
|
|
127
|
+
surfaces: ['tg'],
|
|
128
|
+
scope: 'gateway',
|
|
129
|
+
bypassesBrain: true,
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
name: 'background',
|
|
133
|
+
description: 'Move the current turn to background',
|
|
134
|
+
surfaces: ['tg'],
|
|
135
|
+
scope: 'gateway',
|
|
136
|
+
bypassesBrain: true,
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
name: 'restart',
|
|
140
|
+
description: 'Restart the active session',
|
|
141
|
+
surfaces: ['tg'],
|
|
142
|
+
scope: 'gateway',
|
|
143
|
+
bypassesBrain: true,
|
|
144
|
+
},
|
|
145
|
+
]
|
|
146
|
+
|
|
147
|
+
export function commandsForSurface(surface: CommandSurface): SlashCommand[] {
|
|
148
|
+
return COMMAND_REGISTRY.filter(c => c.surfaces.includes(surface))
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function findCommand(name: string): SlashCommand | undefined {
|
|
152
|
+
const needle = name.replace(/^\/+/, '').toLowerCase()
|
|
153
|
+
return COMMAND_REGISTRY.find(c => c.name === needle)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface ParsedSlash {
|
|
157
|
+
/** Raw command name lowercased, no leading slash. */
|
|
158
|
+
name: string
|
|
159
|
+
/** Whitespace-split argv after the command. */
|
|
160
|
+
args: string[]
|
|
161
|
+
/** Resolved registry entry when name matches a known command, otherwise undefined. */
|
|
162
|
+
command?: SlashCommand
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Parse a leading-slash message into name + args. Returns null when the input
|
|
167
|
+
* doesn't start with a slash. Unknown command names still return a parsed
|
|
168
|
+
* shape (with `command` undefined) so callers can distinguish "not a slash"
|
|
169
|
+
* from "slash but unknown".
|
|
170
|
+
*/
|
|
171
|
+
export function parseSlash(text: string): ParsedSlash | null {
|
|
172
|
+
const trimmed = text.trimStart()
|
|
173
|
+
if (!trimmed.startsWith('/')) return null
|
|
174
|
+
const stripped = trimmed.slice(1).trimEnd()
|
|
175
|
+
if (stripped.length === 0) return null
|
|
176
|
+
const parts = stripped.split(/\s+/)
|
|
177
|
+
const name = (parts[0] ?? '').toLowerCase()
|
|
178
|
+
if (name.length === 0) return null
|
|
179
|
+
const args = parts.slice(1)
|
|
180
|
+
return { name, args, command: findCommand(name) }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Filter commands by surface and prefix for autocomplete suggestions.
|
|
185
|
+
* Empty or single-`/` query returns all commands for the surface.
|
|
186
|
+
*/
|
|
187
|
+
export function suggestForPrefix(surface: CommandSurface, query: string): SlashCommand[] {
|
|
188
|
+
const stripped = query.replace(/^\/+/, '').toLowerCase()
|
|
189
|
+
const all = commandsForSurface(surface)
|
|
190
|
+
if (stripped.length === 0) return all
|
|
191
|
+
return all.filter(c => c.name.startsWith(stripped))
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Permission modes operators can flip via /yolo and /perms. Matches
|
|
196
|
+
* `PermissionMode` from `../permission` but redeclared here to avoid the
|
|
197
|
+
* commands module taking a transitive dependency on the permission service.
|
|
198
|
+
*/
|
|
199
|
+
export type PermissionToggleMode = 'off' | 'prompt' | 'strict'
|
|
200
|
+
|
|
201
|
+
export interface PermissionApi {
|
|
202
|
+
getMode(): PermissionToggleMode
|
|
203
|
+
setMode(mode: PermissionToggleMode): void
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export interface ApplyResult {
|
|
207
|
+
/** Operator-facing message describing the new state. */
|
|
208
|
+
message: string
|
|
209
|
+
/** Mode after the operation, for callers that mirror UI state. */
|
|
210
|
+
mode: PermissionToggleMode
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Toggle approval prompts between `off` (YOLO) and `prompt` (re-enable).
|
|
215
|
+
* `strict` flips to `prompt` on toggle to give an off-ramp from lockdown.
|
|
216
|
+
* Single source of truth; consumed by TUI `/yolo`, TG-local `/yolo`, and
|
|
217
|
+
* gateway TG `/yolo` so wording stays in lockstep.
|
|
218
|
+
*/
|
|
219
|
+
export function applyYolo(permission: PermissionApi): ApplyResult {
|
|
220
|
+
const cur = permission.getMode()
|
|
221
|
+
const next: PermissionToggleMode = cur === 'off' ? 'prompt' : 'off'
|
|
222
|
+
permission.setMode(next)
|
|
223
|
+
return {
|
|
224
|
+
mode: next,
|
|
225
|
+
message:
|
|
226
|
+
next === 'off'
|
|
227
|
+
? 'YOLO ON. Approval prompts disabled for this session.'
|
|
228
|
+
: 'YOLO OFF. Approvals back on.',
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Set the permission mode explicitly. `arg === undefined` returns the current
|
|
234
|
+
* mode without mutation. Returns the same `{ message, mode }` shape so callers
|
|
235
|
+
* have one branch instead of two for "show vs set".
|
|
236
|
+
*/
|
|
237
|
+
export function applyPerms(
|
|
238
|
+
permission: PermissionApi,
|
|
239
|
+
arg: string | undefined,
|
|
240
|
+
): ApplyResult | { message: string; mode: PermissionToggleMode; error: true } {
|
|
241
|
+
if (!arg) {
|
|
242
|
+
const mode = permission.getMode()
|
|
243
|
+
return { mode, message: `perms: ${mode}` }
|
|
244
|
+
}
|
|
245
|
+
const lower = arg.toLowerCase()
|
|
246
|
+
if (lower !== 'off' && lower !== 'prompt' && lower !== 'strict') {
|
|
247
|
+
return {
|
|
248
|
+
mode: permission.getMode(),
|
|
249
|
+
message: `unknown perms mode '${arg}'. try: off | prompt | strict`,
|
|
250
|
+
error: true,
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
permission.setMode(lower as PermissionToggleMode)
|
|
254
|
+
return { mode: lower as PermissionToggleMode, message: `perms set to ${lower}` }
|
|
255
|
+
}
|