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,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
|
+
}
|