@oracle-agent/oracle 0.3.6 → 0.4.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/bin/oracle-data-mcp.mjs +61 -0
- package/package.json +2 -1
- package/plugins/oracle-owner-gate/__init__.py +14 -0
- package/profiles/oracle/SOUL.md +19 -0
- package/src/address-book.mjs +160 -0
package/bin/oracle-data-mcp.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import fs from "node:fs";
|
|
|
14
14
|
import process from "node:process";
|
|
15
15
|
import { fileURLToPath } from "node:url";
|
|
16
16
|
import { env } from "../src/oracle-env.mjs";
|
|
17
|
+
import { listAddresses, lookupAddress, rememberAddress, forgetAddress } from "../src/address-book.mjs";
|
|
17
18
|
|
|
18
19
|
const DATA_URL = (env("ORACLE_DATA_URL", "MAD_DESK_URL", "http://127.0.0.1:8787")).replace(/\/$/, "");
|
|
19
20
|
|
|
@@ -539,6 +540,57 @@ const tools = [
|
|
|
539
540
|
required: ["chainId", "address"],
|
|
540
541
|
},
|
|
541
542
|
},
|
|
543
|
+
{
|
|
544
|
+
name: "address_book_remember",
|
|
545
|
+
description:
|
|
546
|
+
"Remember a wallet address with a label. Call this whenever someone states their address " +
|
|
547
|
+
"or an agent/counterparty wallet is discovered, so it survives the conversation. " +
|
|
548
|
+
"Stores labels only — never private keys, seeds, or passphrases.",
|
|
549
|
+
inputSchema: {
|
|
550
|
+
type: "object",
|
|
551
|
+
required: ["address", "label"],
|
|
552
|
+
properties: {
|
|
553
|
+
address: { type: "string", description: "0x address" },
|
|
554
|
+
label: { type: "string", description: "short name, e.g. carlo-agent" },
|
|
555
|
+
who: { type: "string", description: "person or entity the wallet belongs to" },
|
|
556
|
+
role: { type: "string", description: "agent | owner | person | counterparty | venue" },
|
|
557
|
+
chainIds: { type: "array", items: { type: "number" } },
|
|
558
|
+
notes: { type: "string" },
|
|
559
|
+
},
|
|
560
|
+
},
|
|
561
|
+
},
|
|
562
|
+
{
|
|
563
|
+
name: "address_book_list",
|
|
564
|
+
description:
|
|
565
|
+
"List remembered wallets (owner, agent, people, counterparties). Read-only. " +
|
|
566
|
+
"Check this before sending or bridging to a person.",
|
|
567
|
+
inputSchema: {
|
|
568
|
+
type: "object",
|
|
569
|
+
properties: {
|
|
570
|
+
q: { type: "string", description: "search across label, who, address, notes" },
|
|
571
|
+
role: { type: "string" },
|
|
572
|
+
who: { type: "string" },
|
|
573
|
+
},
|
|
574
|
+
},
|
|
575
|
+
},
|
|
576
|
+
{
|
|
577
|
+
name: "address_book_lookup",
|
|
578
|
+
description: "Look up one 0x address in the durable address book. Read-only.",
|
|
579
|
+
inputSchema: {
|
|
580
|
+
type: "object",
|
|
581
|
+
required: ["address"],
|
|
582
|
+
properties: { address: { type: "string" } },
|
|
583
|
+
},
|
|
584
|
+
},
|
|
585
|
+
{
|
|
586
|
+
name: "address_book_forget",
|
|
587
|
+
description: "Remove a remembered address, optionally only one label for it.",
|
|
588
|
+
inputSchema: {
|
|
589
|
+
type: "object",
|
|
590
|
+
required: ["address"],
|
|
591
|
+
properties: { address: { type: "string" }, label: { type: "string" } },
|
|
592
|
+
},
|
|
593
|
+
},
|
|
542
594
|
{
|
|
543
595
|
name: "best_swap_route",
|
|
544
596
|
description:
|
|
@@ -817,6 +869,15 @@ async function callTool(name, args = {}) {
|
|
|
817
869
|
const token = args.token ? await sc.tokenBalance(args.address, args.token).catch(() => null) : null;
|
|
818
870
|
return { chainId: Number(args.chainId), address: args.address, native, token };
|
|
819
871
|
}
|
|
872
|
+
if (name === "address_book_remember") {
|
|
873
|
+
return rememberAddress({
|
|
874
|
+
address: args.address, label: args.label, who: args.who,
|
|
875
|
+
role: args.role, chainIds: args.chainIds, notes: args.notes, source: "oracle-data-mcp",
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
if (name === "address_book_list") return listAddresses({ q: args.q, role: args.role, who: args.who });
|
|
879
|
+
if (name === "address_book_lookup") return lookupAddress(args.address);
|
|
880
|
+
if (name === "address_book_forget") return forgetAddress(args.address, args.label || null);
|
|
820
881
|
if (name === "data_catalog") return httpJson(`${DATA_URL}/data/catalog`);
|
|
821
882
|
if (name === "data_health") return httpJson(`${DATA_URL}/data/health`);
|
|
822
883
|
if (name === "data_call") {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oracle-agent/oracle",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Oracle: prepare-only multichain agent control plane. Policy-bounded intents for a user-signed wallet. Self-custody by default — the public package never takes your key. Built for Hermes; no model key required.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
"./scanner": "./src/scanner/index.mjs",
|
|
43
43
|
"./router": "./src/router/index.mjs",
|
|
44
44
|
"./action-semantics": "./src/action-semantics.mjs",
|
|
45
|
+
"./address-book": "./src/address-book.mjs",
|
|
45
46
|
"./nft-gas-war": "./src/nft-gas-war-guard.mjs",
|
|
46
47
|
"./prepare-envelope": "./src/prepare-envelope.mjs"
|
|
47
48
|
},
|
|
@@ -26,6 +26,16 @@ _PROTECTED = {
|
|
|
26
26
|
"oracle_operator_sign": "sign",
|
|
27
27
|
"oracle_operator_send": "send",
|
|
28
28
|
"oracle_operator_execute": "send",
|
|
29
|
+
"exec_arm": "execute",
|
|
30
|
+
"exec_disarm": "cancel",
|
|
31
|
+
"evm_sign": "sign",
|
|
32
|
+
"evm_send": "send",
|
|
33
|
+
# The address book is durable state a later send can be routed against, so
|
|
34
|
+
# writes are owner-only. Reads stay open to the owner's own sessions.
|
|
35
|
+
"address_book_remember": "owner_write",
|
|
36
|
+
"address_book_forget": "owner_write",
|
|
37
|
+
"address_book_list": "owner_read",
|
|
38
|
+
"address_book_lookup": "owner_read",
|
|
29
39
|
}
|
|
30
40
|
|
|
31
41
|
_LEADING_INTENT = re.compile(
|
|
@@ -181,6 +191,10 @@ def _on_pre_tool_call(tool_name: str = "", session_id: str = "", **_: Any) -> Op
|
|
|
181
191
|
return _block(reason)
|
|
182
192
|
if required == "owner_read":
|
|
183
193
|
return None
|
|
194
|
+
if required == "owner_write":
|
|
195
|
+
# Owner-authenticated bookkeeping. It records a label; it does not move
|
|
196
|
+
# value and does not need a leading execution verb like `arm`.
|
|
197
|
+
return None
|
|
184
198
|
intent = turn["intent"]
|
|
185
199
|
if intent != required:
|
|
186
200
|
labels = {
|
package/profiles/oracle/SOUL.md
CHANGED
|
@@ -47,6 +47,16 @@ the data plane). If it stays ambiguous, ask. Do not guess a chain.
|
|
|
47
47
|
`watch`, `watch this`, and `ping me` always mean `actionMode: alert_only`.
|
|
48
48
|
Only an explicit `arm` may mean `actionMode: execute`, and only for one exact,
|
|
49
49
|
bounded owner-authorized action.
|
|
50
|
+
|
|
51
|
+
**Arming is a chat action, not a terminal chore.** When the owner says `arm`,
|
|
52
|
+
create the exact action and confirm it. Never tell them to edit an env file,
|
|
53
|
+
export a variable, or restart a service to enable a trade. A local operator
|
|
54
|
+
ships pre-armed for owner-confirmed actions; the walls that still apply are
|
|
55
|
+
owner identity, the venue/destination allowlist, and the exact-grant bind.
|
|
56
|
+
|
|
57
|
+
**Autonomous trading is the one opt-in.** A trigger that fires a trade with
|
|
58
|
+
nobody watching requires `ORACLE_AUTONOMOUS=1`. Until then such an action
|
|
59
|
+
alerts instead of executing. Say that plainly rather than pretending it fired.
|
|
50
60
|
2. **RFQ is a route source, not a permission bypass.** Compare solver/RFQ
|
|
51
61
|
quotes net of gas/spread where configured, enforce expiry, and keep exact
|
|
52
62
|
artifact kinds separate.
|
|
@@ -69,6 +79,15 @@ the data plane). If it stays ambiguous, ask. Do not guess a chain.
|
|
|
69
79
|
incomplete whenever a provider, address, price, token/NFT indexer, or chain
|
|
70
80
|
adapter is missing. Never turn an unavailable historical value into zero.
|
|
71
81
|
|
|
82
|
+
## Address memory
|
|
83
|
+
|
|
84
|
+
Remember who an address belongs to. When someone states a wallet, or an agent /
|
|
85
|
+
counterparty wallet turns up in a read, call `address_book_remember` with a label
|
|
86
|
+
and who it belongs to. Before preparing a send or bridge to a person rather than a
|
|
87
|
+
venue, check `address_book_lookup` / `address_book_list` instead of asking them to
|
|
88
|
+
paste it again. Labels only — never private keys, seeds, or passphrases, and a
|
|
89
|
+
remembered label is a convenience, not an authorization.
|
|
90
|
+
|
|
72
91
|
## Confidence
|
|
73
92
|
|
|
74
93
|
State it explicitly: `high` / `moderate` / `low` / `unknown`. A confident wrong
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable labeled address book for owner/agent/counterparty wallets.
|
|
3
|
+
*
|
|
4
|
+
* On by default: remembering who an address belongs to is a read/annotate
|
|
5
|
+
* concern, not an execution one. Nothing here signs, and no private key,
|
|
6
|
+
* passphrase, or seed is ever accepted or stored.
|
|
7
|
+
*/
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { ORACLE_CONFIG_DIR, env } from "./oracle-env.mjs";
|
|
11
|
+
|
|
12
|
+
const ADDR_RE = /^0x[0-9a-fA-F]{40}$/;
|
|
13
|
+
const FORBIDDEN_KEYS = new Set([
|
|
14
|
+
"privatekey", "private_key", "secret", "seed", "mnemonic", "passphrase", "signature", "keystore",
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
export function addressBookPath() {
|
|
18
|
+
const override = env("ORACLE_ADDRESS_BOOK", "MAD_ADDRESS_BOOK", "");
|
|
19
|
+
if (override) return path.resolve(override);
|
|
20
|
+
const dir = env("ORACLE_CONFIG_DIR", "MAD_CONFIG_DIR", ORACLE_CONFIG_DIR);
|
|
21
|
+
return path.join(dir, "address-book.json");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function assertNoSecrets(input) {
|
|
25
|
+
for (const key of Object.keys(input || {})) {
|
|
26
|
+
if (FORBIDDEN_KEYS.has(key.toLowerCase())) {
|
|
27
|
+
throw new Error(`address-book: ${key} is forbidden; the book stores labels, never key material`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function normalizeAddress(value) {
|
|
33
|
+
const address = String(value || "").trim();
|
|
34
|
+
if (!ADDR_RE.test(address)) throw new Error("address-book: address must be 0x + 40 hex characters");
|
|
35
|
+
return address.toLowerCase();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function text(value, max) {
|
|
39
|
+
if (value == null || value === "") return null;
|
|
40
|
+
return String(value).slice(0, max);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function emptyBook() {
|
|
44
|
+
return { version: 1, updatedAt: null, entries: [] };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function normalizeEntry(entry) {
|
|
48
|
+
return {
|
|
49
|
+
address: String(entry.address).toLowerCase(),
|
|
50
|
+
label: text(entry.label, 80) || "unknown",
|
|
51
|
+
who: text(entry.who, 120),
|
|
52
|
+
role: text(entry.role, 40),
|
|
53
|
+
chainIds: Array.isArray(entry.chainIds) ? entry.chainIds.map(Number).filter(Number.isFinite) : [],
|
|
54
|
+
notes: text(entry.notes, 500),
|
|
55
|
+
source: text(entry.source, 80),
|
|
56
|
+
firstSeenAt: entry.firstSeenAt || null,
|
|
57
|
+
lastSeenAt: entry.lastSeenAt || null,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function readAddressBook() {
|
|
62
|
+
try {
|
|
63
|
+
const raw = JSON.parse(fs.readFileSync(addressBookPath(), "utf8"));
|
|
64
|
+
if (!raw || typeof raw !== "object") return emptyBook();
|
|
65
|
+
const entries = Array.isArray(raw.entries) ? raw.entries : [];
|
|
66
|
+
return {
|
|
67
|
+
version: 1,
|
|
68
|
+
updatedAt: raw.updatedAt || null,
|
|
69
|
+
entries: entries
|
|
70
|
+
.filter((entry) => entry && typeof entry === "object" && ADDR_RE.test(String(entry.address || "")))
|
|
71
|
+
.map(normalizeEntry),
|
|
72
|
+
};
|
|
73
|
+
} catch {
|
|
74
|
+
return emptyBook();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function writeBook(book) {
|
|
79
|
+
const file = addressBookPath();
|
|
80
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
81
|
+
const payload = { ...book, version: 1, updatedAt: new Date().toISOString() };
|
|
82
|
+
const tmp = path.join(path.dirname(file), `.address-book.${process.pid}.tmp`);
|
|
83
|
+
fs.writeFileSync(tmp, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 });
|
|
84
|
+
fs.renameSync(tmp, file);
|
|
85
|
+
try {
|
|
86
|
+
fs.chmodSync(file, 0o600);
|
|
87
|
+
} catch {
|
|
88
|
+
/* best effort on platforms without chmod semantics */
|
|
89
|
+
}
|
|
90
|
+
return payload;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function listAddresses(filter = {}) {
|
|
94
|
+
const book = readAddressBook();
|
|
95
|
+
let entries = book.entries;
|
|
96
|
+
if (filter.role) entries = entries.filter((entry) => entry.role === String(filter.role));
|
|
97
|
+
if (filter.who) {
|
|
98
|
+
const query = String(filter.who).toLowerCase();
|
|
99
|
+
entries = entries.filter(
|
|
100
|
+
(entry) => (entry.who || "").toLowerCase().includes(query) || (entry.label || "").toLowerCase().includes(query),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
if (filter.q) {
|
|
104
|
+
const query = String(filter.q).toLowerCase();
|
|
105
|
+
entries = entries.filter(
|
|
106
|
+
(entry) =>
|
|
107
|
+
entry.address.includes(query) ||
|
|
108
|
+
(entry.label || "").toLowerCase().includes(query) ||
|
|
109
|
+
(entry.who || "").toLowerCase().includes(query) ||
|
|
110
|
+
(entry.notes || "").toLowerCase().includes(query),
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
return { updatedAt: book.updatedAt, count: entries.length, entries };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function lookupAddress(address) {
|
|
117
|
+
const normalized = normalizeAddress(address);
|
|
118
|
+
const entries = readAddressBook().entries.filter((entry) => entry.address === normalized);
|
|
119
|
+
return { address: normalized, found: entries.length > 0, entries };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function rememberAddress(input = {}) {
|
|
123
|
+
assertNoSecrets(input);
|
|
124
|
+
const address = normalizeAddress(input.address);
|
|
125
|
+
const label = text(input.label ?? input.role, 80) || "wallet";
|
|
126
|
+
const now = new Date().toISOString();
|
|
127
|
+
const book = readAddressBook();
|
|
128
|
+
const index = book.entries.findIndex((entry) => entry.address === address && entry.label === label);
|
|
129
|
+
const entry = normalizeEntry({
|
|
130
|
+
address,
|
|
131
|
+
label,
|
|
132
|
+
who: input.who,
|
|
133
|
+
role: input.role,
|
|
134
|
+
chainIds: input.chainIds,
|
|
135
|
+
notes: input.notes,
|
|
136
|
+
source: input.source || "agent",
|
|
137
|
+
firstSeenAt: index >= 0 ? book.entries[index].firstSeenAt || now : now,
|
|
138
|
+
lastSeenAt: now,
|
|
139
|
+
});
|
|
140
|
+
if (index >= 0) book.entries[index] = entry;
|
|
141
|
+
else book.entries.push(entry);
|
|
142
|
+
const rank = (role) => (role === "agent" ? 0 : role === "owner" ? 1 : 2);
|
|
143
|
+
book.entries.sort(
|
|
144
|
+
(a, b) => rank(a.role) - rank(b.role) || a.label.localeCompare(b.label) || a.address.localeCompare(b.address),
|
|
145
|
+
);
|
|
146
|
+
const saved = writeBook(book);
|
|
147
|
+
return { ok: true, entry, count: saved.entries.length };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function forgetAddress(address, label = null) {
|
|
151
|
+
const normalized = normalizeAddress(address);
|
|
152
|
+
const book = readAddressBook();
|
|
153
|
+
const before = book.entries.length;
|
|
154
|
+
book.entries = book.entries.filter((entry) => {
|
|
155
|
+
if (entry.address !== normalized) return true;
|
|
156
|
+
return label == null ? false : entry.label !== String(label);
|
|
157
|
+
});
|
|
158
|
+
writeBook(book);
|
|
159
|
+
return { ok: true, removed: before - book.entries.length, count: book.entries.length };
|
|
160
|
+
}
|