@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.
@@ -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.6",
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 = {
@@ -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
+ }