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,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v0.24.0: gather + write helpers for the slot 0 (memory-index) and slot 3
|
|
3
|
+
* (profile) pack-blob envelopes. Both slots now bundle the root file plus
|
|
4
|
+
* every sibling file in the partition that v0.23.x would have left on
|
|
5
|
+
* local disk only.
|
|
6
|
+
*
|
|
7
|
+
* Slot 0 (agent key, transfers with iNFT):
|
|
8
|
+
* - root: memory/MEMORY.md
|
|
9
|
+
* - files: memory/agent/*.md EXCEPT identity.md (slot 1) + persona.md (slot 2)
|
|
10
|
+
*
|
|
11
|
+
* Slot 3 (operator PROFILE key, purges on transfer):
|
|
12
|
+
* - root: memory/user/profile.md
|
|
13
|
+
* - files: memory/user/*.md EXCEPT profile.md (it's already the root)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
|
|
17
|
+
import { dirname, join } from 'node:path'
|
|
18
|
+
import type { PackBlob } from './pack-blob'
|
|
19
|
+
|
|
20
|
+
/** Files inside memory/agent/ that have their own slot and must NOT be packed. */
|
|
21
|
+
const AGENT_PACK_EXCLUDED = new Set(['identity.md', 'persona.md'])
|
|
22
|
+
|
|
23
|
+
/** Files inside memory/user/ that must NOT be packed (profile.md is the root). */
|
|
24
|
+
const USER_PACK_EXCLUDED = new Set(['profile.md'])
|
|
25
|
+
|
|
26
|
+
export interface GatherResult {
|
|
27
|
+
/** Root file content (empty string if root file is missing/empty). */
|
|
28
|
+
root: string
|
|
29
|
+
/** Sibling files keyed by filename. */
|
|
30
|
+
files: Record<string, string>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Read the agent partition into a {root, files} shape ready for `encodePackBlob`.
|
|
35
|
+
* Missing files yield empty strings; missing partition dir yields empty files.
|
|
36
|
+
*/
|
|
37
|
+
export async function gatherAgentPack(memoryDir: string): Promise<GatherResult> {
|
|
38
|
+
const rootPath = join(memoryDir, 'MEMORY.md')
|
|
39
|
+
const partitionDir = join(memoryDir, 'agent')
|
|
40
|
+
return gatherPack(rootPath, partitionDir, AGENT_PACK_EXCLUDED)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Read the user partition into a {root, files} shape ready for `encodePackBlob`.
|
|
45
|
+
* Missing files yield empty strings; missing partition dir yields empty files.
|
|
46
|
+
*/
|
|
47
|
+
export async function gatherUserPack(memoryDir: string): Promise<GatherResult> {
|
|
48
|
+
const rootPath = join(memoryDir, 'user', 'profile.md')
|
|
49
|
+
const partitionDir = join(memoryDir, 'user')
|
|
50
|
+
return gatherPack(rootPath, partitionDir, USER_PACK_EXCLUDED)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function gatherPack(
|
|
54
|
+
rootPath: string,
|
|
55
|
+
partitionDir: string,
|
|
56
|
+
excludedFilenames: Set<string>,
|
|
57
|
+
): Promise<GatherResult> {
|
|
58
|
+
const root = await readOptional(rootPath)
|
|
59
|
+
const files: Record<string, string> = {}
|
|
60
|
+
let entries: string[]
|
|
61
|
+
try {
|
|
62
|
+
entries = await readdir(partitionDir)
|
|
63
|
+
} catch {
|
|
64
|
+
return { root, files }
|
|
65
|
+
}
|
|
66
|
+
for (const name of entries) {
|
|
67
|
+
if (excludedFilenames.has(name)) continue
|
|
68
|
+
if (!name.endsWith('.md')) continue
|
|
69
|
+
const content = await readOptional(join(partitionDir, name))
|
|
70
|
+
if (content.length === 0) continue
|
|
71
|
+
files[name] = content
|
|
72
|
+
}
|
|
73
|
+
return { root, files }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function readOptional(path: string): Promise<string> {
|
|
77
|
+
try {
|
|
78
|
+
return await readFile(path, 'utf8')
|
|
79
|
+
} catch {
|
|
80
|
+
return ''
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Write the decoded pack contents back to the agent partition. Used by the
|
|
86
|
+
* gateway restore path on cold start. Idempotent: writes the root file and
|
|
87
|
+
* every entry in `files`; does NOT delete files that already exist on disk
|
|
88
|
+
* but are not in the pack (local-wins on conflict).
|
|
89
|
+
*/
|
|
90
|
+
export async function writeAgentPack(memoryDir: string, blob: PackBlob): Promise<void> {
|
|
91
|
+
const rootPath = join(memoryDir, 'MEMORY.md')
|
|
92
|
+
const partitionDir = join(memoryDir, 'agent')
|
|
93
|
+
await writePack(rootPath, partitionDir, blob)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Write the decoded user-partition pack back to disk. Idempotent (see writeAgentPack). */
|
|
97
|
+
export async function writeUserPack(memoryDir: string, blob: PackBlob): Promise<void> {
|
|
98
|
+
const rootPath = join(memoryDir, 'user', 'profile.md')
|
|
99
|
+
const partitionDir = join(memoryDir, 'user')
|
|
100
|
+
await writePack(rootPath, partitionDir, blob)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function writePack(rootPath: string, partitionDir: string, blob: PackBlob): Promise<void> {
|
|
104
|
+
if (blob.root.length > 0) {
|
|
105
|
+
await mkdir(dirname(rootPath), { recursive: true })
|
|
106
|
+
await writeFile(rootPath, blob.root)
|
|
107
|
+
}
|
|
108
|
+
await mkdir(partitionDir, { recursive: true })
|
|
109
|
+
for (const [name, content] of Object.entries(blob.files)) {
|
|
110
|
+
await writeFile(join(partitionDir, name), content)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import matter from 'gray-matter'
|
|
2
|
+
import type { MemoryFrontmatter, MemoryPartition, MemoryTopic } from './types'
|
|
3
|
+
|
|
4
|
+
export function parseTopic(partition: MemoryPartition, slug: string, raw: string): MemoryTopic {
|
|
5
|
+
const parsed = matter(raw)
|
|
6
|
+
const fm = parsed.data as Partial<MemoryFrontmatter>
|
|
7
|
+
if (!fm.name || !fm.description || !fm.type) {
|
|
8
|
+
throw new Error(`Topic file ${slug} missing required frontmatter (name/description/type)`)
|
|
9
|
+
}
|
|
10
|
+
return {
|
|
11
|
+
partition,
|
|
12
|
+
slug,
|
|
13
|
+
frontmatter: fm as MemoryFrontmatter,
|
|
14
|
+
body: parsed.content.trimStart(),
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function stringifyTopic(topic: MemoryTopic): string {
|
|
19
|
+
return matter.stringify(topic.body, topic.frontmatter as Record<string, unknown>)
|
|
20
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { resolve } from 'node:path'
|
|
3
|
+
import { z } from 'zod'
|
|
4
|
+
import { agentPaths } from '../paths'
|
|
5
|
+
import type { ToolDef } from '../tools/types'
|
|
6
|
+
import { readIndexFile } from './index-file'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `memory.read` — fetch a memory file's full body by title, slug, or relative
|
|
10
|
+
* path. Resolution order:
|
|
11
|
+
*
|
|
12
|
+
* 1. If `name` is a relative path under the memory dir → read directly.
|
|
13
|
+
* 2. Look up MEMORY.md: match entry whose title or filename contains the
|
|
14
|
+
* requested string (case-insensitive substring). MEMORY.md is the
|
|
15
|
+
* authoritative registry, so this catches whatever weird filename
|
|
16
|
+
* `memory.save` produced.
|
|
17
|
+
* 3. Try common naming patterns as a last resort.
|
|
18
|
+
*
|
|
19
|
+
* Without this tool the brain only sees the index hook line and can't recall
|
|
20
|
+
* specifics ("what's stored in user-favorite-color.md") on demand.
|
|
21
|
+
*/
|
|
22
|
+
const readSchema = z.object({
|
|
23
|
+
name: z
|
|
24
|
+
.string()
|
|
25
|
+
.min(1)
|
|
26
|
+
.max(256)
|
|
27
|
+
.describe(
|
|
28
|
+
'Memory entry title (from MEMORY.md), slug, or relative path. Examples: `favorite-color`, `Nebula identity`, `user/user-elpabl0.md`.',
|
|
29
|
+
),
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
export type MemoryReadArgs = z.infer<typeof readSchema>
|
|
33
|
+
|
|
34
|
+
export interface MakeMemoryReadToolArgs {
|
|
35
|
+
agentId: string
|
|
36
|
+
/**
|
|
37
|
+
* Override the on-disk agent dir. Gateway daemon writes restored memory
|
|
38
|
+
* under `${TMPDIR}/nebula-gateway/<id>/` while local-mode chat.tsx uses
|
|
39
|
+
* `~/.nebula/agents/<id>/`. Pass the daemon's true agentDir so the brain's
|
|
40
|
+
* memory.read resolves against the same path the gateway just wrote to —
|
|
41
|
+
* otherwise files restored from chain return "not found" because the tool
|
|
42
|
+
* defaults to agentPaths (the legacy location).
|
|
43
|
+
*/
|
|
44
|
+
agentDir?: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function makeMemoryReadTool({
|
|
48
|
+
agentId,
|
|
49
|
+
agentDir,
|
|
50
|
+
}: MakeMemoryReadToolArgs): ToolDef<MemoryReadArgs> {
|
|
51
|
+
return {
|
|
52
|
+
name: 'memory.read',
|
|
53
|
+
description:
|
|
54
|
+
'Read the full body of a memory file. Use to recall specific facts. Match by title from MEMORY.md, slug, or relative path. Tries multiple resolutions before giving up.',
|
|
55
|
+
schema: readSchema,
|
|
56
|
+
handler: async args => {
|
|
57
|
+
const memDir = agentDir ? `${agentDir}/memory` : agentPaths.agent(agentId).memoryDir
|
|
58
|
+
const memoryIndex = agentDir
|
|
59
|
+
? `${agentDir}/memory/MEMORY.md`
|
|
60
|
+
: agentPaths.agent(agentId).memoryIndex
|
|
61
|
+
const query = args.name.trim()
|
|
62
|
+
const safeRead = makeSafeReader(memDir)
|
|
63
|
+
|
|
64
|
+
const tried: string[] = []
|
|
65
|
+
|
|
66
|
+
// 1. Direct relative path with .md (path-traversal-checked).
|
|
67
|
+
if (query.endsWith('.md') && query.includes('/')) {
|
|
68
|
+
const result = await safeRead(query)
|
|
69
|
+
tried.push(query)
|
|
70
|
+
if (result) return success(query, result)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 2. MEMORY.md lookup — match by title or filename. Three passes:
|
|
74
|
+
// (a) exact match on title or file
|
|
75
|
+
// (b) substring of title or file contains the full query
|
|
76
|
+
// (c) token-overlap score (each non-stopword in query that appears
|
|
77
|
+
// in the entry's title+file+hook counts; ties broken by recency)
|
|
78
|
+
// Pass (c) is the one that catches "tool test run" → "Tool test session"
|
|
79
|
+
// — the brain often paraphrases titles when recalling, and substring
|
|
80
|
+
// match alone fails when the operator's word order or extra tokens
|
|
81
|
+
// differ from the canonical name.
|
|
82
|
+
try {
|
|
83
|
+
const idx = await readIndexFile(memoryIndex)
|
|
84
|
+
const q = query.toLowerCase()
|
|
85
|
+
const entries = Array.from(idx.entries.values())
|
|
86
|
+
const exact = entries.find(e => e.title.toLowerCase() === q || e.file.toLowerCase() === q)
|
|
87
|
+
const substringMatch =
|
|
88
|
+
exact ??
|
|
89
|
+
entries.find(e => e.title.toLowerCase().includes(q) || e.file.toLowerCase().includes(q))
|
|
90
|
+
let match = substringMatch
|
|
91
|
+
if (!match) {
|
|
92
|
+
const STOP = new Set([
|
|
93
|
+
'the',
|
|
94
|
+
'a',
|
|
95
|
+
'an',
|
|
96
|
+
'and',
|
|
97
|
+
'or',
|
|
98
|
+
'of',
|
|
99
|
+
'to',
|
|
100
|
+
'in',
|
|
101
|
+
'on',
|
|
102
|
+
'for',
|
|
103
|
+
'is',
|
|
104
|
+
'are',
|
|
105
|
+
'was',
|
|
106
|
+
'were',
|
|
107
|
+
'be',
|
|
108
|
+
'been',
|
|
109
|
+
'i',
|
|
110
|
+
'my',
|
|
111
|
+
'me',
|
|
112
|
+
'you',
|
|
113
|
+
'your',
|
|
114
|
+
'about',
|
|
115
|
+
'remember',
|
|
116
|
+
'what',
|
|
117
|
+
'did',
|
|
118
|
+
'tell',
|
|
119
|
+
'said',
|
|
120
|
+
'told',
|
|
121
|
+
'memory',
|
|
122
|
+
'note',
|
|
123
|
+
'notes',
|
|
124
|
+
])
|
|
125
|
+
const tokens = q.split(/[^a-z0-9]+/).filter(t => t.length >= 2 && !STOP.has(t))
|
|
126
|
+
if (tokens.length > 0) {
|
|
127
|
+
let best: { entry: (typeof entries)[number]; score: number } | null = null
|
|
128
|
+
for (const e of entries) {
|
|
129
|
+
const blob = `${e.title} ${e.file} ${e.hook ?? ''}`.toLowerCase()
|
|
130
|
+
const score = tokens.reduce((s, t) => s + (blob.includes(t) ? 1 : 0), 0)
|
|
131
|
+
if (score > 0 && (best === null || score > best.score)) {
|
|
132
|
+
best = { entry: e, score }
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (best && best.score >= Math.max(1, Math.ceil(tokens.length / 2))) {
|
|
136
|
+
match = best.entry
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (match) {
|
|
141
|
+
const result = await safeRead(match.file)
|
|
142
|
+
tried.push(`MEMORY.md→${match.file}`)
|
|
143
|
+
if (result) return success(match.file, result)
|
|
144
|
+
}
|
|
145
|
+
} catch {
|
|
146
|
+
// MEMORY.md missing or unreadable — fall through to direct paths.
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// 3. Common naming patterns
|
|
150
|
+
const stem = query.replace(/\.md$/, '').replace(/^\/+/, '')
|
|
151
|
+
const fallbacks = [
|
|
152
|
+
`agent/${stem}.md`,
|
|
153
|
+
`user/${stem}.md`,
|
|
154
|
+
`agent/identity-${stem}.md`,
|
|
155
|
+
`agent/learned-${stem}.md`,
|
|
156
|
+
`user/user-${stem}.md`,
|
|
157
|
+
`user/feedback-${stem}.md`,
|
|
158
|
+
`user/project-${stem}.md`,
|
|
159
|
+
`user/reference-${stem}.md`,
|
|
160
|
+
]
|
|
161
|
+
for (const rel of fallbacks) {
|
|
162
|
+
const result = await safeRead(rel)
|
|
163
|
+
tried.push(rel)
|
|
164
|
+
if (result) return success(rel, result)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
ok: false,
|
|
169
|
+
error: `Memory file not found for "${query}". Tried: ${tried.join(', ')}`,
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Returns a reader that refuses to escape the agent's memory directory.
|
|
177
|
+
* Prevents `../../etc/passwd.md`-style traversal even if a malicious memory
|
|
178
|
+
* entry steers the brain into asking for an out-of-tree path.
|
|
179
|
+
*/
|
|
180
|
+
function makeSafeReader(memDir: string) {
|
|
181
|
+
const root = resolve(memDir)
|
|
182
|
+
return async (relPath: string): Promise<string | null> => {
|
|
183
|
+
const full = resolve(memDir, relPath)
|
|
184
|
+
if (full !== root && !full.startsWith(`${root}/`)) {
|
|
185
|
+
return null
|
|
186
|
+
}
|
|
187
|
+
try {
|
|
188
|
+
return await readFile(full, 'utf8')
|
|
189
|
+
} catch (e) {
|
|
190
|
+
if ((e as NodeJS.ErrnoException).code === 'ENOENT') return null
|
|
191
|
+
throw e
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function success(path: string, content: string) {
|
|
197
|
+
return { ok: true, data: { path, content } }
|
|
198
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import { agentPaths } from '../paths'
|
|
4
|
+
import type { ToolDef } from '../tools/types'
|
|
5
|
+
import { addEntryLine, readIndexFile, writeIndexFile } from './index-file'
|
|
6
|
+
import { scanForThreats } from './scan'
|
|
7
|
+
import { readTopic, writeTopic } from './topic'
|
|
8
|
+
import {
|
|
9
|
+
MEMORY_TYPES,
|
|
10
|
+
type MemoryFrontmatter,
|
|
11
|
+
type MemoryPartition,
|
|
12
|
+
type MemoryTopic,
|
|
13
|
+
type MemoryType,
|
|
14
|
+
} from './types'
|
|
15
|
+
|
|
16
|
+
const saveSchema = z.object({
|
|
17
|
+
name: z.string().min(3).max(64).describe('Short human-readable title for this memory.'),
|
|
18
|
+
description: z
|
|
19
|
+
.string()
|
|
20
|
+
.min(10)
|
|
21
|
+
.max(240)
|
|
22
|
+
.describe('One-line description used to decide relevance in future sessions. Be specific.'),
|
|
23
|
+
type: z
|
|
24
|
+
.enum(MEMORY_TYPES)
|
|
25
|
+
.describe(
|
|
26
|
+
'Memory type. agent-* transfers with iNFT; user/feedback/project/reference are operator-scoped and purge on transfer.',
|
|
27
|
+
),
|
|
28
|
+
/** For MVP we only support full-body rewrite. Edit ops follow in phase 3.5+. */
|
|
29
|
+
content: z
|
|
30
|
+
.string()
|
|
31
|
+
.min(1)
|
|
32
|
+
.max(10_000)
|
|
33
|
+
.describe('Full markdown body of the memory (no frontmatter — it gets added).'),
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
export type MemorySaveArgs = z.infer<typeof saveSchema>
|
|
37
|
+
|
|
38
|
+
/** Shape returned in `data` from a successful memory.save call. */
|
|
39
|
+
export interface MemorySaveData {
|
|
40
|
+
file: string
|
|
41
|
+
partition: MemoryPartition
|
|
42
|
+
slug: string
|
|
43
|
+
updated: boolean
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface MakeMemorySaveToolArgs {
|
|
47
|
+
agentId: string
|
|
48
|
+
/**
|
|
49
|
+
* Override the on-disk agent dir (e.g. `${TMPDIR}/nebula-gateway/<id>`).
|
|
50
|
+
* Gateway daemon writes memory under tmpdir, not `~/.nebula/agents/<id>/`.
|
|
51
|
+
* When provided, `topic` + `MEMORY.md` resolve against this root.
|
|
52
|
+
* When absent, fall back to `agentPaths.agent(agentId).dir` for local-mode
|
|
53
|
+
* callers (chat.tsx pre-gateway path).
|
|
54
|
+
*/
|
|
55
|
+
agentDir?: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function makeMemorySaveTool({
|
|
59
|
+
agentId,
|
|
60
|
+
agentDir,
|
|
61
|
+
}: MakeMemorySaveToolArgs): ToolDef<MemorySaveArgs> {
|
|
62
|
+
return {
|
|
63
|
+
name: 'memory.save',
|
|
64
|
+
description:
|
|
65
|
+
'Save a durable fact, preference, or knowledge to long-term memory. Call proactively when you learn non-obvious things about the user or world. Skip derivable info (code patterns, git log, ephemeral state).',
|
|
66
|
+
schema: saveSchema,
|
|
67
|
+
handler: async args => {
|
|
68
|
+
const scan = scanForThreats(args.content)
|
|
69
|
+
if (!scan.ok) {
|
|
70
|
+
return {
|
|
71
|
+
ok: false,
|
|
72
|
+
error: `Content rejected by threat scan: ${scan.violations.map(v => v.id).join(', ')}`,
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const partition = partitionForType(args.type)
|
|
77
|
+
const slug = toSlug(args.name, args.type)
|
|
78
|
+
const dir = agentDir ?? agentPaths.agent(agentId).dir
|
|
79
|
+
const now = new Date().toISOString()
|
|
80
|
+
|
|
81
|
+
const existing = await readTopic(dir, partition, slug)
|
|
82
|
+
const isProfile = slug === PROFILE_SLUG && partition === 'user'
|
|
83
|
+
const fm: MemoryFrontmatter = {
|
|
84
|
+
name: isProfile ? PROFILE_SLUG : args.name,
|
|
85
|
+
description: isProfile
|
|
86
|
+
? (existing?.frontmatter.description ?? args.description)
|
|
87
|
+
: args.description,
|
|
88
|
+
type: args.type,
|
|
89
|
+
createdAt: existing?.frontmatter.createdAt ?? now,
|
|
90
|
+
updatedAt: now,
|
|
91
|
+
}
|
|
92
|
+
const topic: MemoryTopic = {
|
|
93
|
+
partition,
|
|
94
|
+
slug,
|
|
95
|
+
frontmatter: fm,
|
|
96
|
+
body: existing ? mergeBody(existing.body, args.content, slug) : args.content,
|
|
97
|
+
}
|
|
98
|
+
await writeTopic(dir, topic)
|
|
99
|
+
|
|
100
|
+
const indexPath = agentDir
|
|
101
|
+
? join(agentDir, 'memory', 'MEMORY.md')
|
|
102
|
+
: agentPaths.agent(agentId).memoryIndex
|
|
103
|
+
let index = await readIndexFile(indexPath)
|
|
104
|
+
const file = `${partition}/${slug}.md`
|
|
105
|
+
if (!index.entries.has(file)) {
|
|
106
|
+
index = addEntryLine(index, {
|
|
107
|
+
file,
|
|
108
|
+
title: args.name,
|
|
109
|
+
hook: args.description,
|
|
110
|
+
})
|
|
111
|
+
await writeIndexFile(indexPath, index)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const data: MemorySaveData = { file, partition, slug, updated: existing !== null }
|
|
115
|
+
return { ok: true, data }
|
|
116
|
+
},
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function partitionForType(type: MemoryType): MemoryPartition {
|
|
121
|
+
return type.startsWith('agent-') ? 'agent' : 'user'
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Canonical operator-facts file in the user partition. Anchors to iNFT slot 3.
|
|
126
|
+
* Any user/<other>.md file is local-only scratchpad until v0.24.0 ships the
|
|
127
|
+
* multi-file user partition.
|
|
128
|
+
*/
|
|
129
|
+
export const PROFILE_SLUG = 'profile' as const
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Brain often picks ambiguous names for operator facts ("preferences",
|
|
133
|
+
* "operator profile", "about me", "my preferences"). Consolidate them all
|
|
134
|
+
* into user/profile.md so the fact actually anchors to chain instead of
|
|
135
|
+
* being lost on reprovision.
|
|
136
|
+
*/
|
|
137
|
+
const PROFILE_NAME_PATTERN =
|
|
138
|
+
/^(my[\s_-]?)?(profile|preferences?|about[\s_-]?me|operator[\s_-]?profile|user[\s_-]?profile|operator[\s_-]?preferences?|user[\s_-]?preferences?)$/i
|
|
139
|
+
|
|
140
|
+
export function toSlug(name: string, type: MemoryType): string {
|
|
141
|
+
if (type === 'user' && PROFILE_NAME_PATTERN.test(name.trim())) {
|
|
142
|
+
return PROFILE_SLUG
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
let prefix = ''
|
|
146
|
+
if (type.startsWith('user-')) prefix = type.replace(/^user-/, '')
|
|
147
|
+
else if (type.startsWith('agent-')) prefix = type.replace(/^agent-/, '')
|
|
148
|
+
|
|
149
|
+
const base = name
|
|
150
|
+
.toLowerCase()
|
|
151
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
152
|
+
.replace(/^-|-$/g, '')
|
|
153
|
+
.slice(0, 48)
|
|
154
|
+
return prefix ? `${prefix}-${base}` : base
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function mergeBody(prev: string, add: string, slug: string): string {
|
|
158
|
+
return slug === PROFILE_SLUG ? mergeProfileBody(prev, add) : appendBody(prev, add)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Profile.md grows over time as the brain learns operator facts. Plain append
|
|
163
|
+
* accumulates duplicates ("Operator likes coffee black" written 5 times across
|
|
164
|
+
* sessions). Dedup at line granularity: skip any non-blank line that already
|
|
165
|
+
* appears verbatim in the previous body. Append only fresh lines.
|
|
166
|
+
*
|
|
167
|
+
* Section-level merge (replace `## Heading` blocks) is intentionally NOT done
|
|
168
|
+
* here — the brain doesn't reliably structure profile writes with stable
|
|
169
|
+
* headings, so a line-dedup is the cheapest correct semantics.
|
|
170
|
+
*/
|
|
171
|
+
export function mergeProfileBody(prev: string, add: string): string {
|
|
172
|
+
const prevLines = new Set(
|
|
173
|
+
prev
|
|
174
|
+
.split('\n')
|
|
175
|
+
.map(l => l.trim())
|
|
176
|
+
.filter(l => l.length > 0),
|
|
177
|
+
)
|
|
178
|
+
const freshLines = add
|
|
179
|
+
.split('\n')
|
|
180
|
+
.map(l => l.trim())
|
|
181
|
+
.filter(l => l.length > 0 && !prevLines.has(l))
|
|
182
|
+
if (freshLines.length === 0) return prev
|
|
183
|
+
return `${prev.trimEnd()}\n\n${freshLines.join('\n')}`
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function appendBody(prev: string, add: string): string {
|
|
187
|
+
const trimmed = prev.trimEnd()
|
|
188
|
+
return `${trimmed}\n\n${add}`
|
|
189
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Threat-pattern scan applied to every write. Content that matches any
|
|
3
|
+
* pattern is rejected — this file IS a memory file that gets injected into
|
|
4
|
+
* the brain's prompt, so malicious content = persistent prompt injection.
|
|
5
|
+
*
|
|
6
|
+
* MVP list (extend over time).
|
|
7
|
+
*/
|
|
8
|
+
const PATTERNS: Array<{ id: string; regex: RegExp; reason: string }> = [
|
|
9
|
+
{
|
|
10
|
+
id: 'ignore-previous-instructions',
|
|
11
|
+
regex: /ignore (all |any |previous |prior )?instructions/i,
|
|
12
|
+
reason: 'Prompt injection attempt (ignore-instructions directive).',
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
id: 'role-override',
|
|
16
|
+
regex: /you are (now |actually |a )[^.\n]{3,80}/i,
|
|
17
|
+
reason: 'Prompt injection attempt (role override).',
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
id: 'system-prompt-request',
|
|
21
|
+
regex: /(print|show|reveal|output) (your|the) (system )?prompt/i,
|
|
22
|
+
reason: 'Prompt injection attempt (system-prompt exfil).',
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
id: 'private-key-dump',
|
|
26
|
+
regex: /(private|secret) key is ([0-9a-f]{32,}|0x[0-9a-f]{40,})/i,
|
|
27
|
+
reason: 'Suspicious private-key literal in memory content.',
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
id: 'invisible-unicode',
|
|
31
|
+
// Explicit alternation to avoid ZWJ-composed character classes that
|
|
32
|
+
// biome's noMisleadingCharacterClass rule flags. Covers zero-width
|
|
33
|
+
// space, joiner variants, BOM, and Unicode bidi override markers.
|
|
34
|
+
regex: /|||||||||/u,
|
|
35
|
+
reason: 'Invisible unicode detected (possible hidden instruction).',
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: 'transfer-claim',
|
|
39
|
+
regex: /transfer.*(inft|agent).*(without|bypass|skip).*(tee|verification|signature)/i,
|
|
40
|
+
reason: 'Suspicious transfer/TEE-bypass claim.',
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: 'exfil-sink',
|
|
44
|
+
regex:
|
|
45
|
+
/(curl|fetch|wget|nc) [^\n]{10,}[@:.]([a-z0-9.-]+\.(?!(nebula|local|localhost|127\.0\.0\.1))[a-z]{2,})/i,
|
|
46
|
+
reason: 'Command-line exfiltration pattern in memory content.',
|
|
47
|
+
},
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
export interface ThreatScanResult {
|
|
51
|
+
ok: boolean
|
|
52
|
+
violations: Array<{ id: string; reason: string }>
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function scanForThreats(content: string): ThreatScanResult {
|
|
56
|
+
const violations: Array<{ id: string; reason: string }> = []
|
|
57
|
+
for (const p of PATTERNS) {
|
|
58
|
+
if (p.regex.test(content)) {
|
|
59
|
+
violations.push({ id: p.id, reason: p.reason })
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return { ok: violations.length === 0, violations }
|
|
63
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
import { parseTopic, stringifyTopic } from './parser'
|
|
4
|
+
import type { MemoryPartition, MemoryTopic } from './types'
|
|
5
|
+
|
|
6
|
+
export async function readTopic(
|
|
7
|
+
dir: string,
|
|
8
|
+
partition: MemoryPartition,
|
|
9
|
+
slug: string,
|
|
10
|
+
): Promise<MemoryTopic | null> {
|
|
11
|
+
const path = topicPath(dir, partition, slug)
|
|
12
|
+
const raw = await readFile(path, 'utf8').catch(e => {
|
|
13
|
+
if ((e as NodeJS.ErrnoException).code === 'ENOENT') return null
|
|
14
|
+
throw e
|
|
15
|
+
})
|
|
16
|
+
if (raw === null) return null
|
|
17
|
+
return parseTopic(partition, slug, raw)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function writeTopic(dir: string, topic: MemoryTopic): Promise<void> {
|
|
21
|
+
const path = topicPath(dir, topic.partition, topic.slug)
|
|
22
|
+
await mkdir(dirname(path), { recursive: true })
|
|
23
|
+
|
|
24
|
+
const tmp = `${path}.tmp-${process.pid}-${Date.now().toString(36)}`
|
|
25
|
+
const body = stringifyTopic(topic)
|
|
26
|
+
await writeFile(tmp, body, 'utf8')
|
|
27
|
+
await rename(tmp, path)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function topicPath(dir: string, partition: MemoryPartition, slug: string): string {
|
|
31
|
+
return join(dir, 'memory', partition, `${slug}.md`)
|
|
32
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export const MEMORY_TYPES = [
|
|
2
|
+
'agent-identity',
|
|
3
|
+
'agent-persona',
|
|
4
|
+
'agent-learned',
|
|
5
|
+
'user',
|
|
6
|
+
'user-convos',
|
|
7
|
+
'user-private',
|
|
8
|
+
'feedback',
|
|
9
|
+
'project',
|
|
10
|
+
'reference',
|
|
11
|
+
] as const
|
|
12
|
+
|
|
13
|
+
export type MemoryType = (typeof MEMORY_TYPES)[number]
|
|
14
|
+
|
|
15
|
+
export type MemoryPartition = 'agent' | 'user' | 'public'
|
|
16
|
+
|
|
17
|
+
export interface MemoryFrontmatter {
|
|
18
|
+
name: string
|
|
19
|
+
description: string
|
|
20
|
+
type: MemoryType
|
|
21
|
+
/** ISO timestamp, set on first write. */
|
|
22
|
+
createdAt?: string
|
|
23
|
+
/** ISO timestamp, updated on every write. */
|
|
24
|
+
updatedAt?: string
|
|
25
|
+
/** Free-form extra fields preserved on round-trip. */
|
|
26
|
+
[key: string]: unknown
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface MemoryTopic {
|
|
30
|
+
partition: MemoryPartition
|
|
31
|
+
/** Filename without `.md` extension, e.g. `feedback-testing`. */
|
|
32
|
+
slug: string
|
|
33
|
+
frontmatter: MemoryFrontmatter
|
|
34
|
+
/** Full markdown body below frontmatter. */
|
|
35
|
+
body: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface MemoryIndexEntry {
|
|
39
|
+
file: string
|
|
40
|
+
title: string
|
|
41
|
+
hook: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface MemoryIndex {
|
|
45
|
+
/** Raw lines from MEMORY.md preserved in order. */
|
|
46
|
+
lines: string[]
|
|
47
|
+
/** Parsed index entries keyed by file. */
|
|
48
|
+
entries: Map<string, MemoryIndexEntry>
|
|
49
|
+
}
|