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,53 @@
1
+ export type EditAction = 'add' | 'replace' | 'remove'
2
+
3
+ export interface EditOp {
4
+ action: EditAction
5
+ /** Required for replace/remove. Substring to match in the existing body. */
6
+ oldText?: string
7
+ /** Required for add/replace. Text to insert. */
8
+ newText?: string
9
+ }
10
+
11
+ export class EditError extends Error {
12
+ constructor(
13
+ message: string,
14
+ readonly op: EditOp,
15
+ ) {
16
+ super(message)
17
+ this.name = 'EditError'
18
+ }
19
+ }
20
+
21
+ /**
22
+ * Apply a single edit op to `body`. Substring-based matching (hermes pattern),
23
+ * no IDs or line numbers. Returns the new body.
24
+ */
25
+ export function applyEdit(body: string, op: EditOp): string {
26
+ switch (op.action) {
27
+ case 'add': {
28
+ if (op.newText === undefined) throw new EditError('add requires newText', op)
29
+ return body.length === 0 ? op.newText : `${body.trimEnd()}\n\n${op.newText}\n`
30
+ }
31
+ case 'replace': {
32
+ if (op.oldText === undefined || op.newText === undefined) {
33
+ throw new EditError('replace requires oldText AND newText', op)
34
+ }
35
+ const idx = locateUnique(body, op.oldText, op)
36
+ return body.slice(0, idx) + op.newText + body.slice(idx + op.oldText.length)
37
+ }
38
+ case 'remove': {
39
+ if (op.oldText === undefined) throw new EditError('remove requires oldText', op)
40
+ const idx = locateUnique(body, op.oldText, op)
41
+ return body.slice(0, idx) + body.slice(idx + op.oldText.length)
42
+ }
43
+ }
44
+ }
45
+
46
+ function locateUnique(body: string, needle: string, op: EditOp): number {
47
+ const idx = body.indexOf(needle)
48
+ if (idx < 0) throw new EditError('oldText not found in body', op)
49
+ if (body.indexOf(needle, idx + 1) >= 0) {
50
+ throw new EditError('oldText is not unique in body', op)
51
+ }
52
+ return idx
53
+ }
@@ -0,0 +1,88 @@
1
+ import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from 'node:crypto'
2
+ import { gunzipSync, gzipSync } from 'node:zlib'
3
+ import type { Hex } from 'viem'
4
+ import { hexToBytes } from 'viem'
5
+
6
+ /**
7
+ * Phase 6.7 memory file encryption.
8
+ *
9
+ * Key derivation: HKDF-SHA256(ikm = agent privkey bytes, info = "nebula-memory-aead-v1")
10
+ * → 32-byte AES-256-GCM key.
11
+ *
12
+ * Why agent privkey (not operator wallet)? Memory writes happen mid-chat —
13
+ * thousands of times in a long conversation. Asking the operator wallet to
14
+ * sign per write would be miserable for WC users. The agent privkey is
15
+ * already in RAM during the chat session (decrypted via operator at session
16
+ * start), so deriving a memory key from it is silent and fast.
17
+ *
18
+ * Recovery: anyone who can decrypt the keystore can derive this key, so the
19
+ * security envelope is the same as the keystore's.
20
+ *
21
+ * Format: v(1) || iv(12) || tag(16) || ciphertext (raw bytes, no JSON wrap).
22
+ * v=1: plaintext encrypted directly (legacy).
23
+ * v=2: plaintext gzip-compressed first then encrypted. Decryption gunzips
24
+ * after AES-GCM. Used for the activity-log slot where JSON content
25
+ * compresses 5-10x and the Mantle Storage upload is the bottleneck.
26
+ *
27
+ * Both versions are backwards-compatible: decryptMemoryBytes dispatches on
28
+ * the leading version byte and reads either layout.
29
+ */
30
+ export const MEMORY_BLOB_VERSION = 1 as const
31
+ export const MEMORY_BLOB_VERSION_GZIP = 2 as const
32
+
33
+ const HKDF_INFO = Buffer.from('nebula-memory-aead-v1', 'utf8')
34
+ const KEY_LEN = 32
35
+ const IV_LEN = 12
36
+ const TAG_LEN = 16
37
+
38
+ export function deriveMemoryKey(agentPrivkey: Hex): Buffer {
39
+ const ikm = Buffer.from(hexToBytes(agentPrivkey))
40
+ return Buffer.from(hkdfSync('sha256', ikm, Buffer.alloc(0), HKDF_INFO, KEY_LEN))
41
+ }
42
+
43
+ export interface EncryptOpts {
44
+ /**
45
+ * Gzip the plaintext before encrypting. Reduces blob size 5-10x on JSON-
46
+ * heavy content like the activity log. Costs a few ms of CPU per upload
47
+ * — fine because the network upload it saves is much slower. Default
48
+ * false to preserve byte-for-byte compatibility with v=1 callers.
49
+ */
50
+ compress?: boolean
51
+ }
52
+
53
+ export function encryptMemoryBytes(
54
+ plaintext: Uint8Array,
55
+ key: Buffer,
56
+ opts: EncryptOpts = {},
57
+ ): Uint8Array {
58
+ if (key.length !== KEY_LEN) throw new Error(`memory key must be ${KEY_LEN} bytes`)
59
+ const iv = randomBytes(IV_LEN)
60
+ const cipher = createCipheriv('aes-256-gcm', key, iv)
61
+ const payload = opts.compress ? gzipSync(plaintext) : plaintext
62
+ const ct = Buffer.concat([cipher.update(payload), cipher.final()])
63
+ const tag = cipher.getAuthTag()
64
+ const version = opts.compress ? MEMORY_BLOB_VERSION_GZIP : MEMORY_BLOB_VERSION
65
+ return new Uint8Array(Buffer.concat([Buffer.from([version]), iv, tag, ct]))
66
+ }
67
+
68
+ export function decryptMemoryBytes(blob: Uint8Array, key: Buffer): Uint8Array {
69
+ if (key.length !== KEY_LEN) throw new Error(`memory key must be ${KEY_LEN} bytes`)
70
+ const buf = Buffer.from(blob)
71
+ if (buf.length < 1 + IV_LEN + TAG_LEN) {
72
+ throw new Error(`memory blob too short: ${buf.length} bytes`)
73
+ }
74
+ const version = buf[0]
75
+ if (version !== MEMORY_BLOB_VERSION && version !== MEMORY_BLOB_VERSION_GZIP) {
76
+ throw new Error(`unsupported memory blob version: ${version}`)
77
+ }
78
+ const iv = buf.subarray(1, 1 + IV_LEN)
79
+ const tag = buf.subarray(1 + IV_LEN, 1 + IV_LEN + TAG_LEN)
80
+ const ct = buf.subarray(1 + IV_LEN + TAG_LEN)
81
+ const decipher = createDecipheriv('aes-256-gcm', key, iv)
82
+ decipher.setAuthTag(tag)
83
+ const decrypted = Buffer.concat([decipher.update(ct), decipher.final()])
84
+ if (version === MEMORY_BLOB_VERSION_GZIP) {
85
+ return new Uint8Array(gunzipSync(decrypted))
86
+ }
87
+ return new Uint8Array(decrypted)
88
+ }
@@ -0,0 +1,15 @@
1
+ import { readFile } from 'node:fs/promises'
2
+
3
+ /**
4
+ * Read a file as bytes; return null on ENOENT, rethrow other errors.
5
+ * Used wherever an "absent file is fine, anything else is a bug" semantic
6
+ * is needed (memory sync, activity log sync, on-chain diff vs local).
7
+ */
8
+ export async function readOrNull(path: string): Promise<Uint8Array | null> {
9
+ try {
10
+ return new Uint8Array(await readFile(path))
11
+ } catch (e) {
12
+ if ((e as NodeJS.ErrnoException).code === 'ENOENT') return null
13
+ throw e
14
+ }
15
+ }
@@ -0,0 +1,74 @@
1
+ import { readFile, rename, writeFile } from 'node:fs/promises'
2
+ import type { MemoryIndex, MemoryIndexEntry } from './types'
3
+
4
+ /** Max enforced by Claude Code conventions — loaded into every prompt. */
5
+ export const INDEX_LINE_LIMIT = 200
6
+ export const INDEX_BYTE_LIMIT = 25 * 1024
7
+
8
+ const ENTRY_RE = /^-\s*\[([^\]]+)\]\(([^)]+)\)(?:\s*[-—]\s*(.*))?$/
9
+
10
+ export function parseIndex(raw: string): MemoryIndex {
11
+ const lines = raw.split('\n')
12
+ const entries = new Map<string, MemoryIndexEntry>()
13
+ for (const line of lines) {
14
+ const m = line.match(ENTRY_RE)
15
+ if (m?.[1] && m[2]) {
16
+ const title = m[1]
17
+ const file = m[2]
18
+ const hook = m[3] ?? ''
19
+ entries.set(file, { file, title, hook: hook.trim() })
20
+ }
21
+ }
22
+ return { lines, entries }
23
+ }
24
+
25
+ export function stringifyIndex(index: MemoryIndex): string {
26
+ const joined = index.lines.join('\n')
27
+ return joined.endsWith('\n') ? joined : `${joined}\n`
28
+ }
29
+
30
+ export async function readIndexFile(path: string): Promise<MemoryIndex> {
31
+ const raw = await readFile(path, 'utf8').catch(e => {
32
+ if ((e as NodeJS.ErrnoException).code === 'ENOENT') return ''
33
+ throw e
34
+ })
35
+ return parseIndex(raw)
36
+ }
37
+
38
+ export async function writeIndexFile(path: string, index: MemoryIndex): Promise<void> {
39
+ const content = stringifyIndex(index)
40
+ if (content.length > INDEX_BYTE_LIMIT) {
41
+ throw new Error(`MEMORY.md exceeds ${INDEX_BYTE_LIMIT}-byte cap (got ${content.length})`)
42
+ }
43
+ if (index.lines.length > INDEX_LINE_LIMIT) {
44
+ throw new Error(`MEMORY.md exceeds ${INDEX_LINE_LIMIT}-line cap (got ${index.lines.length})`)
45
+ }
46
+ const tmp = `${path}.tmp-${process.pid}-${Date.now().toString(36)}`
47
+ await writeFile(tmp, content, 'utf8')
48
+ await rename(tmp, path)
49
+ }
50
+
51
+ export function addEntryLine(index: MemoryIndex, entry: MemoryIndexEntry): MemoryIndex {
52
+ if (index.entries.has(entry.file)) return index
53
+ const line = `- [${entry.title}](${entry.file}) — ${entry.hook}`
54
+ const next: MemoryIndex = {
55
+ lines: [...index.lines, line],
56
+ entries: new Map(index.entries),
57
+ }
58
+ next.entries.set(entry.file, entry)
59
+ return next
60
+ }
61
+
62
+ export function removeEntryLine(index: MemoryIndex, file: string): MemoryIndex {
63
+ if (!index.entries.has(file)) return index
64
+ const filtered = index.lines.filter(line => {
65
+ const m = line.match(ENTRY_RE)
66
+ return !(m && m[2] === file)
67
+ })
68
+ const next: MemoryIndex = {
69
+ lines: filtered,
70
+ entries: new Map(index.entries),
71
+ }
72
+ next.entries.delete(file)
73
+ return next
74
+ }
@@ -0,0 +1,99 @@
1
+ import { readFile, stat } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import matter from 'gray-matter'
4
+ import { addEntryLine, readIndexFile, writeIndexFile } from './index-file'
5
+
6
+ /**
7
+ * v0.23.0: MEMORY.md historically only listed user-partition emergent topic
8
+ * files; the agent-partition meta-files (identity, persona) were anchored to
9
+ * the iNFT but invisible to brain enumeration via the index. Result: the
10
+ * brain's `memory.read name=identity` would only succeed via the slug fallback
11
+ * branch; `memory.list` would report agent[] files but the brain wouldn't
12
+ * see them in narrative MEMORY.md prose.
13
+ *
14
+ * This module adds synthetic top-of-index entries for the canonical
15
+ * agent-partition + the user/profile.md anchor whenever those files exist
16
+ * on disk. Idempotent (matches by file path). Runs at boot-restore and at
17
+ * every sync.doFlush() so the index stays current.
18
+ */
19
+ export interface SyntheticIndexFile {
20
+ /** Path relative to memoryDir, e.g. `agent/identity.md`. */
21
+ file: string
22
+ /** Title used when frontmatter `name` is absent. */
23
+ fallbackTitle: string
24
+ }
25
+
26
+ export const STANDARD_SYNTHETIC_INDEX_FILES: readonly SyntheticIndexFile[] = [
27
+ { file: 'agent/identity.md', fallbackTitle: 'identity' },
28
+ { file: 'agent/persona.md', fallbackTitle: 'persona' },
29
+ { file: 'user/profile.md', fallbackTitle: 'profile' },
30
+ ]
31
+
32
+ export interface SyntheticIndexResult {
33
+ added: string[]
34
+ skipped: string[]
35
+ }
36
+
37
+ export async function ensureSyntheticIndexEntries(
38
+ memoryDir: string,
39
+ files: readonly SyntheticIndexFile[] = STANDARD_SYNTHETIC_INDEX_FILES,
40
+ ): Promise<SyntheticIndexResult> {
41
+ const indexPath = join(memoryDir, 'MEMORY.md')
42
+ let index: Awaited<ReturnType<typeof readIndexFile>>
43
+ try {
44
+ index = await readIndexFile(indexPath)
45
+ } catch {
46
+ // Index file missing or unreadable — skip silently. seedStarterMemoryFiles
47
+ // creates it at init; existing agents that pre-date that path may need a
48
+ // one-time backfill via migration.
49
+ return { added: [], skipped: files.map(f => f.file) }
50
+ }
51
+
52
+ const added: string[] = []
53
+ const skipped: string[] = []
54
+
55
+ for (const f of files) {
56
+ if (index.entries.has(f.file)) {
57
+ skipped.push(f.file)
58
+ continue
59
+ }
60
+ const fsPath = join(memoryDir, f.file)
61
+ if (!(await fileExists(fsPath))) {
62
+ skipped.push(f.file)
63
+ continue
64
+ }
65
+ let title = f.fallbackTitle
66
+ let description: string | null = null
67
+ try {
68
+ const content = await readFile(fsPath, 'utf8')
69
+ const head = content.length > 4096 ? content.slice(0, 4096) : content
70
+ const parsed = matter(head)
71
+ const fm = parsed.data as { name?: string; description?: string }
72
+ if (fm.name && typeof fm.name === 'string') title = fm.name
73
+ if (fm.description && typeof fm.description === 'string') description = fm.description
74
+ } catch {
75
+ // bad frontmatter — fall back to filename
76
+ }
77
+ index = addEntryLine(index, {
78
+ file: f.file,
79
+ title,
80
+ hook: description ?? title,
81
+ })
82
+ added.push(f.file)
83
+ }
84
+
85
+ if (added.length > 0) {
86
+ await writeIndexFile(indexPath, index)
87
+ }
88
+
89
+ return { added, skipped }
90
+ }
91
+
92
+ async function fileExists(path: string): Promise<boolean> {
93
+ try {
94
+ const s = await stat(path)
95
+ return s.isFile() && s.size > 0
96
+ } catch {
97
+ return false
98
+ }
99
+ }
@@ -0,0 +1,58 @@
1
+ export type {
2
+ MemoryType,
3
+ MemoryPartition,
4
+ MemoryFrontmatter,
5
+ MemoryTopic,
6
+ MemoryIndexEntry,
7
+ MemoryIndex,
8
+ } from './types'
9
+ export { MEMORY_TYPES } from './types'
10
+ export { parseTopic, stringifyTopic } from './parser'
11
+ export { scanForThreats, type ThreatScanResult } from './scan'
12
+ export { applyEdit, EditError, type EditOp, type EditAction } from './edit'
13
+ export {
14
+ parseIndex,
15
+ stringifyIndex,
16
+ readIndexFile,
17
+ writeIndexFile,
18
+ addEntryLine,
19
+ removeEntryLine,
20
+ INDEX_LINE_LIMIT,
21
+ INDEX_BYTE_LIMIT,
22
+ } from './index-file'
23
+ export { readTopic, writeTopic, topicPath } from './topic'
24
+ export { makeMemorySaveTool, type MemorySaveArgs } from './save-tool'
25
+ export { makeMemoryReadTool, type MemoryReadArgs } from './read-tool'
26
+ export {
27
+ makeMemoryListTool,
28
+ type MemoryListArgs,
29
+ type MemoryListAgentFile,
30
+ } from './list-tool'
31
+ export {
32
+ ensureSyntheticIndexEntries,
33
+ STANDARD_SYNTHETIC_INDEX_FILES,
34
+ type SyntheticIndexFile,
35
+ type SyntheticIndexResult,
36
+ } from './index-sync'
37
+ export {
38
+ MEMORY_BLOB_VERSION,
39
+ deriveMemoryKey,
40
+ encryptMemoryBytes,
41
+ decryptMemoryBytes,
42
+ } from './encryption'
43
+ export { readOrNull } from './fs-util'
44
+ export {
45
+ PACK_BLOB_VERSION,
46
+ encodePackBlob,
47
+ decodePackBlob,
48
+ isV2Envelope,
49
+ type PackBlob,
50
+ type EncodePackOpts,
51
+ } from './pack-blob'
52
+ export {
53
+ gatherAgentPack,
54
+ gatherUserPack,
55
+ writeAgentPack,
56
+ writeUserPack,
57
+ type GatherResult,
58
+ } from './pack-gather'
@@ -0,0 +1,105 @@
1
+ import { readFile, readdir, stat } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import matter from 'gray-matter'
4
+ import { z } from 'zod'
5
+ import { agentPaths } from '../paths'
6
+ import type { ToolDef } from '../tools/types'
7
+
8
+ /**
9
+ * `memory.list` — enumerate every memory file the agent has stored locally.
10
+ *
11
+ * Returns two sections:
12
+ * - `agent[]`: files under `memory/agent/` (identity, persona, learned-*)
13
+ * - `user[]`: files under `memory/user/` (feedback, project, reference, profile)
14
+ *
15
+ * Use when the operator asks to enumerate what the agent knows. `memory.read`
16
+ * fetches individual file bodies; this tool just lists what's available.
17
+ */
18
+ const listSchema = z.object({})
19
+
20
+ export type MemoryListArgs = z.infer<typeof listSchema>
21
+
22
+ export interface MemoryListAgentFile {
23
+ file: string
24
+ title: string
25
+ description: string | null
26
+ bytes: number
27
+ }
28
+
29
+ export interface MakeMemoryListToolArgs {
30
+ agentId: string
31
+ agentDir?: string
32
+ }
33
+
34
+ export function makeMemoryListTool(opts: MakeMemoryListToolArgs): ToolDef<MemoryListArgs> {
35
+ const memDir = opts.agentDir
36
+ ? join(opts.agentDir, 'memory')
37
+ : agentPaths.agent(opts.agentId).memoryDir
38
+ return {
39
+ name: 'memory.list',
40
+ description:
41
+ "Enumerate every memory file the agent has stored (agent + user partitions). Call when the operator asks 'show me all your memory' / 'what do you remember' / 'list everything you have stored'. Returns two sections: agent (identity, persona, learned-*) and user (feedback, project, reference, profile).",
42
+ schema: listSchema,
43
+ handler: async () => {
44
+ const [agentFiles, userFiles] = await Promise.all([
45
+ listPartition(memDir, 'agent'),
46
+ listPartition(memDir, 'user'),
47
+ ])
48
+ return {
49
+ ok: true,
50
+ data: {
51
+ agent: agentFiles,
52
+ user: userFiles,
53
+ },
54
+ }
55
+ },
56
+ }
57
+ }
58
+
59
+ async function listPartition(
60
+ memDir: string,
61
+ partition: 'agent' | 'user',
62
+ ): Promise<MemoryListAgentFile[]> {
63
+ const dir = join(memDir, partition)
64
+ let names: string[]
65
+ try {
66
+ names = await readdir(dir)
67
+ } catch {
68
+ return []
69
+ }
70
+ const results = await Promise.all(
71
+ names
72
+ .filter(n => n.endsWith('.md'))
73
+ .map(async name => {
74
+ const filePath = join(dir, name)
75
+ try {
76
+ const [statResult, content] = await Promise.all([
77
+ stat(filePath),
78
+ readFile(filePath, 'utf8'),
79
+ ])
80
+ if (!statResult.isFile()) return null
81
+ // gray-matter on first 4KB is enough for frontmatter parse.
82
+ const head = content.length > 4096 ? content.slice(0, 4096) : content
83
+ let title = name.replace(/\.md$/, '')
84
+ let description: string | null = null
85
+ try {
86
+ const parsed = matter(head)
87
+ const fm = parsed.data as { name?: string; description?: string }
88
+ if (fm.name && typeof fm.name === 'string') title = fm.name
89
+ if (fm.description && typeof fm.description === 'string') description = fm.description
90
+ } catch {
91
+ // Bad frontmatter — fall back to filename.
92
+ }
93
+ return {
94
+ file: `${partition}/${name}`,
95
+ title,
96
+ description,
97
+ bytes: statResult.size,
98
+ } satisfies MemoryListAgentFile
99
+ } catch {
100
+ return null
101
+ }
102
+ }),
103
+ )
104
+ return results.filter((r): r is MemoryListAgentFile => r !== null)
105
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * v0.24.0: versioned envelope format for the memory-index (slot 0) and
3
+ * profile (slot 3) blobs. The legacy v1 layout stored a single markdown
4
+ * file's raw text. The v2 envelope wraps the root file's content plus
5
+ * an arbitrary map of additional sibling files so the harness can anchor
6
+ * the whole partition with one slot per partition.
7
+ *
8
+ * Why: pre-v0.24.0, only the 6 hard-coded `RESTORE_TARGETS` files survived
9
+ * reprovision. Every other `agent/*.md` and `user/*.md` was local-only
10
+ * scratchpad, and MEMORY.md retained dangling references after a fresh
11
+ * sandbox boot. The iNFT contract caps slots at 6 per token and is
12
+ * immutable, so adding new slots is impossible without re-minting every
13
+ * existing agent. The fix: extend the encoding of slots 0 + 3 without
14
+ * touching the contract.
15
+ *
16
+ * Envelope shape (plaintext, pre-encryption):
17
+ *
18
+ * { "v": 2,
19
+ * "root": "<markdown body of the canonical file>",
20
+ * "files": { "<filename>.md": "<markdown body>", ... } }
21
+ *
22
+ * - For slot 0 (memory-index, agent key): `root` is MEMORY.md text.
23
+ * `files` keys are paths under `memory/agent/` (e.g. `learned-foo.md`).
24
+ * `identity.md` and `persona.md` are NOT packed here — they keep their
25
+ * own slots (1, 2).
26
+ * - For slot 3 (profile, operator PROFILE key): `root` is profile.md text.
27
+ * `files` keys are paths under `memory/user/` (e.g.
28
+ * `operator-preferences.md`). `profile.md` itself is NOT a `files` key
29
+ * (its content is in `root`).
30
+ *
31
+ * Backwards compat:
32
+ *
33
+ * - `isV2Envelope(bytes)`: cheap byte sniff — first non-whitespace char
34
+ * must be `{` AND the parsed object must have `"v": 2`.
35
+ * - Decoders fall through to legacy v1 (raw markdown) when sniff fails.
36
+ * - Encoders default to v2; pass `legacy: true` for the old single-file
37
+ * format if a caller specifically needs v1 wire-compat.
38
+ *
39
+ * Filename sanitization:
40
+ *
41
+ * Keys in `files` must match `^[a-z0-9][a-z0-9._-]{0,63}\.md$` to keep
42
+ * the pack format predictable. The brain's slug generator (`toSlug` in
43
+ * `save-tool.ts`) already produces filenames that match. Unsafe names
44
+ * (path traversal, absolute paths, weird chars) are rejected at encode
45
+ * time.
46
+ */
47
+
48
+ const SAFE_NAME = /^[a-z0-9][a-z0-9._-]{0,63}\.md$/
49
+
50
+ export const PACK_BLOB_VERSION = 2 as const
51
+
52
+ export interface PackBlob {
53
+ v: typeof PACK_BLOB_VERSION
54
+ /** Root file content (MEMORY.md for slot 0, profile.md for slot 3). */
55
+ root: string
56
+ /** Additional packed files keyed by filename. May be empty. */
57
+ files: Record<string, string>
58
+ }
59
+
60
+ export interface EncodePackOpts {
61
+ root: string
62
+ files?: Record<string, string>
63
+ }
64
+
65
+ /** Encode a pack blob to UTF-8 bytes ready for AES-GCM encryption. */
66
+ export function encodePackBlob(opts: EncodePackOpts): Uint8Array {
67
+ const files: Record<string, string> = {}
68
+ for (const [name, content] of Object.entries(opts.files ?? {})) {
69
+ if (!SAFE_NAME.test(name)) {
70
+ throw new Error(`pack-blob: unsafe filename ${JSON.stringify(name)}`)
71
+ }
72
+ files[name] = content
73
+ }
74
+ const blob: PackBlob = { v: PACK_BLOB_VERSION, root: opts.root, files }
75
+ return new TextEncoder().encode(JSON.stringify(blob))
76
+ }
77
+
78
+ /**
79
+ * Returns true if `bytes` looks like a v2 envelope (starts with `{` and
80
+ * parses to `{ v: 2 }`). Cheap enough to call on every restore.
81
+ */
82
+ export function isV2Envelope(bytes: Uint8Array): boolean {
83
+ if (bytes.length < 8) return false
84
+ for (let i = 0; i < bytes.length && i < 16; i++) {
85
+ const b = bytes[i] as number
86
+ if (b === 0x7b /* { */) break
87
+ if (b === 0x20 || b === 0x09 || b === 0x0a || b === 0x0d) continue
88
+ return false
89
+ }
90
+ try {
91
+ const text = new TextDecoder().decode(bytes)
92
+ const parsed = JSON.parse(text) as { v?: unknown }
93
+ return parsed && typeof parsed === 'object' && parsed.v === PACK_BLOB_VERSION
94
+ } catch {
95
+ return false
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Decode a v2 envelope. Throws if `bytes` is not a valid v2 envelope.
101
+ * Caller is expected to have run `isV2Envelope` first when handling
102
+ * legacy/v2 mixed input.
103
+ */
104
+ export function decodePackBlob(bytes: Uint8Array): PackBlob {
105
+ const text = new TextDecoder().decode(bytes)
106
+ const parsed = JSON.parse(text) as Partial<PackBlob>
107
+ if (parsed.v !== PACK_BLOB_VERSION) {
108
+ throw new Error(`pack-blob: expected v=${PACK_BLOB_VERSION}, got ${parsed.v}`)
109
+ }
110
+ if (typeof parsed.root !== 'string') {
111
+ throw new Error('pack-blob: missing root field')
112
+ }
113
+ const files: Record<string, string> = {}
114
+ for (const [name, content] of Object.entries(parsed.files ?? {})) {
115
+ if (typeof content !== 'string') continue
116
+ if (!SAFE_NAME.test(name)) continue
117
+ files[name] = content
118
+ }
119
+ return { v: PACK_BLOB_VERSION, root: parsed.root, files }
120
+ }