nebula-ai-core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/README.md +24 -0
  2. package/package.json +69 -0
  3. package/src/brain/compaction.ts +131 -0
  4. package/src/brain/frozen-prefix.ts +320 -0
  5. package/src/brain/history-persist.ts +154 -0
  6. package/src/brain/index.ts +43 -0
  7. package/src/brain/openai-brain.ts +533 -0
  8. package/src/brain/sanitize.ts +23 -0
  9. package/src/brain/stub.ts +20 -0
  10. package/src/brain/types.ts +129 -0
  11. package/src/chain.ts +75 -0
  12. package/src/claude-plugins/discovery.ts +152 -0
  13. package/src/claude-plugins/index.ts +6 -0
  14. package/src/claude-plugins/types.ts +38 -0
  15. package/src/commands/index.ts +16 -0
  16. package/src/commands/registry.ts +255 -0
  17. package/src/config.ts +213 -0
  18. package/src/economy/index.ts +6 -0
  19. package/src/events/index.ts +4 -0
  20. package/src/events/listeners.ts +37 -0
  21. package/src/events/queue.ts +63 -0
  22. package/src/events/router.ts +42 -0
  23. package/src/events/types.ts +28 -0
  24. package/src/format.ts +12 -0
  25. package/src/identity/agent-card.ts +110 -0
  26. package/src/identity/deployments.ts +20 -0
  27. package/src/identity/erc8004.ts +161 -0
  28. package/src/identity/index.ts +29 -0
  29. package/src/identity/keystore-blob.ts +60 -0
  30. package/src/identity/receipt.ts +27 -0
  31. package/src/identity/stub.ts +29 -0
  32. package/src/identity/types.ts +20 -0
  33. package/src/index.ts +372 -0
  34. package/src/locks.ts +233 -0
  35. package/src/mcp/discovery.ts +150 -0
  36. package/src/mcp/index.ts +10 -0
  37. package/src/mcp/manager.ts +110 -0
  38. package/src/mcp/stdio-client.ts +154 -0
  39. package/src/mcp/types.ts +44 -0
  40. package/src/memory/edit.ts +53 -0
  41. package/src/memory/encryption.ts +88 -0
  42. package/src/memory/fs-util.ts +15 -0
  43. package/src/memory/index-file.ts +74 -0
  44. package/src/memory/index-sync.ts +99 -0
  45. package/src/memory/index.ts +58 -0
  46. package/src/memory/list-tool.ts +105 -0
  47. package/src/memory/pack-blob.ts +120 -0
  48. package/src/memory/pack-gather.ts +112 -0
  49. package/src/memory/parser.ts +20 -0
  50. package/src/memory/read-tool.ts +198 -0
  51. package/src/memory/save-tool.ts +189 -0
  52. package/src/memory/scan.ts +63 -0
  53. package/src/memory/topic.ts +32 -0
  54. package/src/memory/types.ts +49 -0
  55. package/src/migration/index.ts +6 -0
  56. package/src/migration/option3-crypto.ts +127 -0
  57. package/src/operator/index.ts +9 -0
  58. package/src/operator/keychain.ts +53 -0
  59. package/src/operator/keystore-file.ts +33 -0
  60. package/src/operator/privkey-base.ts +60 -0
  61. package/src/operator/raw-privkey.ts +39 -0
  62. package/src/operator/signer.ts +46 -0
  63. package/src/operator/walletconnect.ts +454 -0
  64. package/src/pairing.ts +285 -0
  65. package/src/paths.ts +70 -0
  66. package/src/permission/dangerous.ts +108 -0
  67. package/src/permission/env-redact.ts +54 -0
  68. package/src/permission/index.ts +16 -0
  69. package/src/permission/path-guard.ts +114 -0
  70. package/src/permission/service.ts +191 -0
  71. package/src/plugins/context.ts +225 -0
  72. package/src/plugins/hooks.ts +81 -0
  73. package/src/plugins/index.ts +24 -0
  74. package/src/plugins/tool-search.ts +49 -0
  75. package/src/public/card.ts +67 -0
  76. package/src/runtime/activity.ts +29 -0
  77. package/src/runtime/index.ts +2 -0
  78. package/src/runtime/runtime.ts +113 -0
  79. package/src/sandbox/credentials.ts +25 -0
  80. package/src/sandbox/docker.ts +396 -0
  81. package/src/sandbox/factory.ts +99 -0
  82. package/src/sandbox/index.ts +15 -0
  83. package/src/sandbox/linux.ts +141 -0
  84. package/src/sandbox/local.ts +19 -0
  85. package/src/sandbox/macos.ts +71 -0
  86. package/src/sandbox/seatbelt-profile.ts +139 -0
  87. package/src/sandbox/types.ts +129 -0
  88. package/src/skills/index.ts +8 -0
  89. package/src/skills/scanner.ts +257 -0
  90. package/src/skills/triggers.ts +78 -0
  91. package/src/skills/types.ts +37 -0
  92. package/src/storage/encryption.ts +87 -0
  93. package/src/storage/factory.ts +31 -0
  94. package/src/storage/index.ts +11 -0
  95. package/src/storage/local-stub.ts +70 -0
  96. package/src/storage/sqlite.ts +95 -0
  97. package/src/storage/types.ts +21 -0
  98. package/src/tools/escalation.ts +200 -0
  99. package/src/tools/index.ts +11 -0
  100. package/src/tools/registry.ts +152 -0
  101. package/src/tools/types.ts +65 -0
  102. package/src/tools/zod-helpers.ts +36 -0
  103. package/src/tools/zod-schema.ts +99 -0
  104. package/src/wallet/drain.ts +79 -0
  105. package/src/wallet/eoa.ts +51 -0
  106. package/src/wallet/index.ts +47 -0
  107. package/src/wallet/keystore.ts +50 -0
  108. package/src/wallet/operator-keystore-crypto.ts +530 -0
  109. package/src/wallet/operator-session.ts +344 -0
@@ -0,0 +1,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
+ }
@@ -0,0 +1,6 @@
1
+ export {
2
+ encryptToPubkey,
3
+ decryptWithPrivkey,
4
+ generateBootstrapKeypair,
5
+ type Option3Envelope,
6
+ } from './option3-crypto'