@ixo/oracle-runtime-workers 0.23.0 → 0.25.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/package.json +5 -4
- package/src/action-proposals/store.ts +0 -40
- package/src/action-proposals/topic-store.ts +0 -42
- package/src/core/env.ts +4 -1
- package/src/delivery/plan.ts +4 -1
- package/src/do/contracts.ts +0 -1
- package/src/do/user-oracle-do.ts +278 -71
- package/src/hub-actions/client.ts +58 -5
- package/src/hub-actions/outcome-signature.ts +227 -0
- package/src/hub-actions/request.ts +16 -7
- package/src/hub-actions/store.ts +61 -37
- package/src/hub-actions/transfer.ts +182 -0
- package/src/memory/session-history-indexer.ts +30 -3
- package/src/memory/user-context-fetcher.ts +36 -12
- package/src/plugin-api/types.ts +25 -2
- package/src/plugins/hub-actions/hub-actions.plugin.ts +154 -25
- package/src/plugins/memory/index.ts +4 -0
- package/src/plugins/memory/memory-entity.ts +49 -0
- package/src/plugins/memory/memory-tools.ts +7 -4
- package/src/plugins/memory/memory-ucan.ts +10 -1
- package/src/plugins/memory/memory.plugin.ts +22 -7
- package/src/shell/app.ts +36 -20
|
@@ -16,7 +16,10 @@
|
|
|
16
16
|
* - Auth is the oracle's two-hop UCAN invocation minted from the user's
|
|
17
17
|
* delegation (`ixo:memory` / `memory/*`) — no delegation, no indexing.
|
|
18
18
|
* - Endpoint: `POST <MEMORY_ENGINE_URL>/messages` with `x-room-id`, as
|
|
19
|
-
* `@ixo/common`'s `MemoryEngineService.processConversationHistory
|
|
19
|
+
* `@ixo/common`'s `MemoryEngineService.processConversationHistory`, plus
|
|
20
|
+
* `x-oracle-entity-did`: the engine keys the user's space by the oracle's
|
|
21
|
+
* domain entity (`ORACLE_ENTITY_DID`). Without one nothing is indexed
|
|
22
|
+
* (one warning per indexer) and the watermark stays put.
|
|
20
23
|
* - A per-session lock prevents overlapping runs; a failed upload is
|
|
21
24
|
* retried 3× with a 10 s pause (Node's numbers).
|
|
22
25
|
*
|
|
@@ -24,6 +27,7 @@
|
|
|
24
27
|
* preferred name is set; here the fallback is the literal "Me" straight away.
|
|
25
28
|
*/
|
|
26
29
|
import type { Logger } from '../plugin-api/types';
|
|
30
|
+
import { MEMORY_ENTITY_HEADER } from '../plugins/memory/memory-entity';
|
|
27
31
|
|
|
28
32
|
export interface HistoryMessage {
|
|
29
33
|
/** LangChain message type: `human` | `ai` | `tool` | `system`. */
|
|
@@ -53,6 +57,12 @@ export interface SpeakerLabels {
|
|
|
53
57
|
export interface SessionHistoryIndexerDeps {
|
|
54
58
|
/** `MEMORY_ENGINE_URL`; undefined disables indexing (memory plugin absent). */
|
|
55
59
|
memoryEngineUrl: string | undefined;
|
|
60
|
+
/**
|
|
61
|
+
* The configured `ORACLE_ENTITY_DID` (a well-formed domain entity DID),
|
|
62
|
+
* sent as `x-oracle-entity-did`. Never the `ORACLE_DID` fallback:
|
|
63
|
+
* undefined → every session is `skipped`.
|
|
64
|
+
*/
|
|
65
|
+
entityDid: string | undefined;
|
|
56
66
|
getSession(sessionId: string): Promise<IndexableSession | undefined>;
|
|
57
67
|
/** The session transcript, summarisation bookkeeping already removed. */
|
|
58
68
|
listMessages(sessionId: string): Promise<HistoryMessage[]>;
|
|
@@ -153,6 +163,9 @@ export class SessionHistoryIndexer {
|
|
|
153
163
|
|
|
154
164
|
private readonly requestTimeoutMs: number;
|
|
155
165
|
|
|
166
|
+
/** The missing-entity warning has been logged (it is logged once). */
|
|
167
|
+
private warnedNoEntity = false;
|
|
168
|
+
|
|
156
169
|
constructor(private readonly deps: SessionHistoryIndexerDeps) {
|
|
157
170
|
this.fetchImpl = deps.fetchImpl ?? fetch.bind(globalThis);
|
|
158
171
|
this.sleep =
|
|
@@ -187,6 +200,16 @@ export class SessionHistoryIndexer {
|
|
|
187
200
|
);
|
|
188
201
|
return 'skipped';
|
|
189
202
|
}
|
|
203
|
+
const entityDid = this.deps.entityDid;
|
|
204
|
+
if (!entityDid) {
|
|
205
|
+
if (!this.warnedNoEntity) {
|
|
206
|
+
this.warnedNoEntity = true;
|
|
207
|
+
logger.warn(
|
|
208
|
+
`[history-indexer] ORACLE_ENTITY_DID is not a domain entity DID (did:ixo:entity:<32 hex>); the memory engine keys every space by it, so session history is not indexed`,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
return 'skipped';
|
|
212
|
+
}
|
|
190
213
|
if (this.inFlight.has(sessionId)) {
|
|
191
214
|
logger.log(
|
|
192
215
|
`[history-indexer] session ${sessionId} is already being indexed, skipping`,
|
|
@@ -197,7 +220,7 @@ export class SessionHistoryIndexer {
|
|
|
197
220
|
try {
|
|
198
221
|
for (let attempt = 1; attempt <= this.maxRetries; attempt += 1) {
|
|
199
222
|
try {
|
|
200
|
-
const outcome = await this.processOnce(sessionId);
|
|
223
|
+
const outcome = await this.processOnce(sessionId, entityDid);
|
|
201
224
|
if (outcome === 'processed') {
|
|
202
225
|
logger.log(
|
|
203
226
|
`[history-indexer] indexed session ${sessionId} into the memory engine`,
|
|
@@ -223,7 +246,10 @@ export class SessionHistoryIndexer {
|
|
|
223
246
|
}
|
|
224
247
|
}
|
|
225
248
|
|
|
226
|
-
private async processOnce(
|
|
249
|
+
private async processOnce(
|
|
250
|
+
sessionId: string,
|
|
251
|
+
entityDid: string,
|
|
252
|
+
): Promise<IndexOutcome> {
|
|
227
253
|
const { deps } = this;
|
|
228
254
|
const { logger } = deps;
|
|
229
255
|
const memoryEngineUrl = deps.memoryEngineUrl!.replace(/\/+$/, '');
|
|
@@ -306,6 +332,7 @@ export class SessionHistoryIndexer {
|
|
|
306
332
|
headers: {
|
|
307
333
|
Authorization: `Bearer ${invocation}`,
|
|
308
334
|
'X-Auth-Type': 'ucan',
|
|
335
|
+
[MEMORY_ENTITY_HEADER]: entityDid,
|
|
309
336
|
'x-room-id': roomId,
|
|
310
337
|
'Content-Type': 'application/json',
|
|
311
338
|
},
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* memory-context block before the model's first call.
|
|
10
10
|
*
|
|
11
11
|
* Policy:
|
|
12
|
-
* - The result is a function of the room (and the oracle
|
|
12
|
+
* - The result is a function of the room (and the oracle's domain entity,
|
|
13
|
+
* which keys every memory space on the engine), not the session,
|
|
13
14
|
* so the cache is keyed by room: a new session in the same room reuses it.
|
|
14
15
|
* Positive entries live `cacheTtlMs` (5 min) in an LRU of `maxCachedRooms`.
|
|
15
16
|
* A successful batch is cached even when every slot is empty — "no
|
|
@@ -25,7 +26,9 @@
|
|
|
25
26
|
* a memory write and after session history is indexed.
|
|
26
27
|
* - A missing signing key or a failed invocation mint is a skip, never a
|
|
27
28
|
* negative-cache entry: the user may delegate `ixo:memory` before the next
|
|
28
|
-
* turn.
|
|
29
|
+
* turn. So is a missing domain entity (`ORACLE_ENTITY_DID`): the engine
|
|
30
|
+
* refuses a request that names none, so none is sent; it is warned about
|
|
31
|
+
* once per fetcher.
|
|
29
32
|
* - One gather per room at a time; a second caller joins it. The blocking
|
|
30
33
|
* `fetch` waits at most `blockingCapMs`; past the cap the gather keeps
|
|
31
34
|
* running and fills the cache for later turns (the caller keeps the
|
|
@@ -41,6 +44,7 @@
|
|
|
41
44
|
*/
|
|
42
45
|
import type { MemoryContextSection } from '../core/prompt-composer';
|
|
43
46
|
import type { Logger, UserContextData } from '../plugin-api/types';
|
|
47
|
+
import { MEMORY_ENTITY_HEADER } from '../plugins/memory/memory-entity';
|
|
44
48
|
import type {
|
|
45
49
|
ComparisonOperator,
|
|
46
50
|
EntityType,
|
|
@@ -99,15 +103,17 @@ const RECENT_WINDOW_DAYS = 90;
|
|
|
99
103
|
* brittle), `node_labels` only where it discriminates, and the `recent`
|
|
100
104
|
* slot bounded to the last 90 days explicitly (the server's
|
|
101
105
|
* `recent_memory` strategy injects the same lower bound and keeps an
|
|
102
|
-
* existing one).
|
|
106
|
+
* existing one). `oracle_dids` names the oracle's domain entity.
|
|
103
107
|
*/
|
|
104
108
|
export function buildUserContextQueries(
|
|
105
|
-
|
|
109
|
+
entityDid: string,
|
|
106
110
|
now: Date,
|
|
107
111
|
): UserContextQuery[] {
|
|
108
112
|
const recentSince = new Date(now.getTime());
|
|
109
113
|
recentSince.setUTCDate(recentSince.getUTCDate() - RECENT_WINDOW_DAYS);
|
|
110
|
-
|
|
114
|
+
// The wire name predates entity keying; the engine reads its entries as
|
|
115
|
+
// domain entity DIDs (the knowledge spaces the queries also search).
|
|
116
|
+
const oracle_dids = [entityDid];
|
|
111
117
|
return [
|
|
112
118
|
{
|
|
113
119
|
oracle_dids,
|
|
@@ -471,8 +477,13 @@ function perSlot<T>(
|
|
|
471
477
|
export interface UserContextFetcherDeps {
|
|
472
478
|
/** `MEMORY_ENGINE_URL`; undefined → every call is `skipped`, no request. */
|
|
473
479
|
memoryEngineUrl: string | undefined;
|
|
474
|
-
/**
|
|
475
|
-
|
|
480
|
+
/**
|
|
481
|
+
* The configured `ORACLE_ENTITY_DID` (a well-formed domain entity DID) —
|
|
482
|
+
* sent as `x-oracle-entity-did` and as the queries' `oracle_dids`. Never
|
|
483
|
+
* the `ORACLE_DID` fallback: undefined → every call is `skipped`
|
|
484
|
+
* (`no-entity`), no request, one warning per fetcher.
|
|
485
|
+
*/
|
|
486
|
+
entityDid: string | undefined;
|
|
476
487
|
/** The user's UCAN surface, bound to the user's DID by the caller. */
|
|
477
488
|
ucan: {
|
|
478
489
|
hasSigningKey(): boolean;
|
|
@@ -515,6 +526,7 @@ const DEFAULT_MAX_CACHED_ROOMS = 64;
|
|
|
515
526
|
|
|
516
527
|
export type UserContextSkipReason =
|
|
517
528
|
| 'no-engine-url'
|
|
529
|
+
| 'no-entity'
|
|
518
530
|
| 'no-signing-key'
|
|
519
531
|
| 'no-invocation'
|
|
520
532
|
| 'recently-failed'
|
|
@@ -616,6 +628,9 @@ export class UserContextFetcher {
|
|
|
616
628
|
|
|
617
629
|
private readonly maxCachedRooms: number;
|
|
618
630
|
|
|
631
|
+
/** The missing-entity warning has been logged (it is logged once). */
|
|
632
|
+
private warnedNoEntity = false;
|
|
633
|
+
|
|
619
634
|
constructor(
|
|
620
635
|
private readonly deps: UserContextFetcherDeps,
|
|
621
636
|
options: UserContextFetcherOptions = {},
|
|
@@ -773,6 +788,16 @@ export class UserContextFetcher {
|
|
|
773
788
|
);
|
|
774
789
|
return { kind: 'skipped', reason: 'no-engine-url' };
|
|
775
790
|
}
|
|
791
|
+
const entityDid = this.deps.entityDid;
|
|
792
|
+
if (!entityDid) {
|
|
793
|
+
if (!this.warnedNoEntity) {
|
|
794
|
+
this.warnedNoEntity = true;
|
|
795
|
+
logger.warn(
|
|
796
|
+
`[user-context] skipped: ORACLE_ENTITY_DID is not a domain entity DID (did:ixo:entity:<32 hex>); the memory engine keys every space by it, so no first-turn context is gathered`,
|
|
797
|
+
);
|
|
798
|
+
}
|
|
799
|
+
return { kind: 'skipped', reason: 'no-entity' };
|
|
800
|
+
}
|
|
776
801
|
if (!roomId) {
|
|
777
802
|
logger.warn(`[user-context] skipped: no oracle room for this turn`);
|
|
778
803
|
return { kind: 'skipped', reason: 'no-room' };
|
|
@@ -807,7 +832,7 @@ export class UserContextFetcher {
|
|
|
807
832
|
|
|
808
833
|
const entry: InFlightGather = {
|
|
809
834
|
startedAt: this.now(),
|
|
810
|
-
promise: this.gather(engineUrl, roomId).then(
|
|
835
|
+
promise: this.gather(engineUrl, entityDid, roomId).then(
|
|
811
836
|
(result) => this.settle(roomId, entry, result),
|
|
812
837
|
(err: unknown) =>
|
|
813
838
|
this.settle(roomId, entry, {
|
|
@@ -870,6 +895,7 @@ export class UserContextFetcher {
|
|
|
870
895
|
/** Mint the invocation, run the batch, map the slots. Never rejects. */
|
|
871
896
|
private async gather(
|
|
872
897
|
engineUrl: string,
|
|
898
|
+
entityDid: string,
|
|
873
899
|
roomId: string,
|
|
874
900
|
): Promise<GatherResult> {
|
|
875
901
|
const { logger, ucan } = this.deps;
|
|
@@ -905,14 +931,12 @@ export class UserContextFetcher {
|
|
|
905
931
|
headers: {
|
|
906
932
|
Authorization: `Bearer ${invocation}`,
|
|
907
933
|
'X-Auth-Type': 'ucan',
|
|
934
|
+
[MEMORY_ENTITY_HEADER]: entityDid,
|
|
908
935
|
'x-room-id': roomId,
|
|
909
936
|
'Content-Type': 'application/json',
|
|
910
937
|
},
|
|
911
938
|
body: JSON.stringify({
|
|
912
|
-
queries: buildUserContextQueries(
|
|
913
|
-
this.deps.oracleDid,
|
|
914
|
-
new Date(this.now()),
|
|
915
|
-
),
|
|
939
|
+
queries: buildUserContextQueries(entityDid, new Date(this.now())),
|
|
916
940
|
}),
|
|
917
941
|
signal: AbortSignal.timeout(this.requestTimeoutMs),
|
|
918
942
|
});
|
package/src/plugin-api/types.ts
CHANGED
|
@@ -762,7 +762,26 @@ export interface RuntimeContext<TConfig = MergedConfig> {
|
|
|
762
762
|
provider: string;
|
|
763
763
|
bindingId: string;
|
|
764
764
|
bindingRevision: number;
|
|
765
|
-
}): Promise<import('@ixo/action-protocol').
|
|
765
|
+
}): Promise<import('@ixo/action-protocol').HubActionOptionsV3>;
|
|
766
|
+
/**
|
|
767
|
+
* A bank send of a token from the owner's options list: `amount` is a
|
|
768
|
+
* decimal in the token's display units; the host converts it to base
|
|
769
|
+
* units from the listed decimals and builds the operation (no fee).
|
|
770
|
+
*/
|
|
771
|
+
transfer(input: {
|
|
772
|
+
ownerDid: string;
|
|
773
|
+
sessionId: string;
|
|
774
|
+
runId: string;
|
|
775
|
+
requestId: string;
|
|
776
|
+
provider: string;
|
|
777
|
+
bindingId: string;
|
|
778
|
+
bindingRevision: number;
|
|
779
|
+
requestKey: string;
|
|
780
|
+
toAddress: string;
|
|
781
|
+
denom: string;
|
|
782
|
+
amount: string;
|
|
783
|
+
memo: string;
|
|
784
|
+
}): Promise<ActionReferenceV1>;
|
|
766
785
|
request(input: {
|
|
767
786
|
ownerDid: string;
|
|
768
787
|
sessionId: string;
|
|
@@ -774,7 +793,11 @@ export interface RuntimeContext<TConfig = MergedConfig> {
|
|
|
774
793
|
requestKey: string;
|
|
775
794
|
operation: ActionOperationV1;
|
|
776
795
|
}): Promise<ActionReferenceV1>;
|
|
777
|
-
|
|
796
|
+
/** The latest authenticated outcome and, for a transfer, its owner-facing line. */
|
|
797
|
+
status(
|
|
798
|
+
ownerDid: string,
|
|
799
|
+
actionId: string,
|
|
800
|
+
): Promise<{ outcome: ActionOutcomeV1 | null; summary: string | null }>;
|
|
778
801
|
};
|
|
779
802
|
|
|
780
803
|
decisions: {
|
|
@@ -24,9 +24,37 @@ const statusSchema = z.strictObject({
|
|
|
24
24
|
actionId: z.string().regex(/^[A-Za-z0-9_-]{32}$/),
|
|
25
25
|
});
|
|
26
26
|
const discoverSchema = z.strictObject({});
|
|
27
|
+
const transferSchema = z.strictObject({
|
|
28
|
+
toAddress: z
|
|
29
|
+
.string()
|
|
30
|
+
.min(1)
|
|
31
|
+
.max(100)
|
|
32
|
+
.describe('The recipient address the user gave, exactly.'),
|
|
33
|
+
denom: z
|
|
34
|
+
.string()
|
|
35
|
+
.min(1)
|
|
36
|
+
.max(128)
|
|
37
|
+
.describe(
|
|
38
|
+
'The token, copied exactly from a `denom` in hub_action_options `transfer.tokens` (never guessed).',
|
|
39
|
+
),
|
|
40
|
+
amount: z
|
|
41
|
+
.string()
|
|
42
|
+
.regex(/^(0|[1-9][0-9]*)(\.[0-9]+)?$/)
|
|
43
|
+
.max(80)
|
|
44
|
+
.describe(
|
|
45
|
+
"A decimal amount in the token's listed units (its `decimals`); for an unverified token (decimals 0) a whole number of base units.",
|
|
46
|
+
),
|
|
47
|
+
memo: z.string().max(256).optional(),
|
|
48
|
+
});
|
|
27
49
|
|
|
28
50
|
export type HubActionRequestResult =
|
|
29
|
-
| {
|
|
51
|
+
| {
|
|
52
|
+
status: 'prepared';
|
|
53
|
+
actionReference: ActionReferenceV1;
|
|
54
|
+
summary: string;
|
|
55
|
+
/** How the review reaches the user; set on a chat surface only. */
|
|
56
|
+
note?: string;
|
|
57
|
+
}
|
|
30
58
|
| {
|
|
31
59
|
status: 'unavailable' | 'expired';
|
|
32
60
|
reason: string;
|
|
@@ -34,6 +62,34 @@ export type HubActionRequestResult =
|
|
|
34
62
|
priorActionId?: string;
|
|
35
63
|
};
|
|
36
64
|
|
|
65
|
+
/**
|
|
66
|
+
* What a prepared result tells the model on a chat surface. There the
|
|
67
|
+
* finished run's Reply Plan carries the review as its own `hub-action` part
|
|
68
|
+
* (a card with the title and a review button; its title linked to the review
|
|
69
|
+
* in a plain-text rendering), so text that repeats it reaches the user twice.
|
|
70
|
+
*/
|
|
71
|
+
export const HUB_ACTION_CARD_NOTE =
|
|
72
|
+
'The user is shown this action automatically as a card with its title and a Review action button that opens it in Auth Hub. Do not repeat the link or the title, and do not say that you have not executed or signed anything. Reply with one short line, or nothing.';
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A prepared result. Only a chat turn's reply is a Reply Plan, so only a
|
|
76
|
+
* chat surface gets the note: on the Portal stream no card is added and the
|
|
77
|
+
* model's own text is the whole reply.
|
|
78
|
+
*/
|
|
79
|
+
function preparedResult(
|
|
80
|
+
reference: ActionReferenceV1,
|
|
81
|
+
ctx: RuntimeContext,
|
|
82
|
+
): HubActionRequestResult {
|
|
83
|
+
return {
|
|
84
|
+
status: 'prepared',
|
|
85
|
+
actionReference: reference,
|
|
86
|
+
summary: reference.title,
|
|
87
|
+
...(ctx.session.surface?.kind === 'chat'
|
|
88
|
+
? { note: HUB_ACTION_CARD_NOTE }
|
|
89
|
+
: {}),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
37
93
|
/**
|
|
38
94
|
* The idempotency key the Hub sees: a fixed-length digest of the run and
|
|
39
95
|
* tool call (the Hub allows 120 characters; a run id alone is 72).
|
|
@@ -74,6 +130,15 @@ async function requestHubAction(
|
|
|
74
130
|
let operation;
|
|
75
131
|
try {
|
|
76
132
|
operation = parseOperation(parsed.data.operation);
|
|
133
|
+
if (operation.kind === 'ixo.bank.send.v1')
|
|
134
|
+
// A transfer is built by the runtime from a listed token and a
|
|
135
|
+
// display amount (request_hub_transfer): the model never supplies
|
|
136
|
+
// base units, a denom it chose or a fee.
|
|
137
|
+
return {
|
|
138
|
+
status: 'unavailable',
|
|
139
|
+
reason:
|
|
140
|
+
'Use request_hub_transfer with a token from hub_action_options for a transfer.',
|
|
141
|
+
};
|
|
77
142
|
} catch (error) {
|
|
78
143
|
// The protocol's own words ("Invalid fromAddress"): the model can fix
|
|
79
144
|
// the field instead of guessing.
|
|
@@ -96,28 +161,83 @@ async function requestHubAction(
|
|
|
96
161
|
operation,
|
|
97
162
|
});
|
|
98
163
|
} catch (error) {
|
|
99
|
-
// A
|
|
100
|
-
//
|
|
164
|
+
// A request that expired, a run no user message started, a Hub
|
|
165
|
+
// refusal: the runtime's own words, so the model can tell the user.
|
|
101
166
|
// Anything else stays an error.
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
error.name === 'HubActionRefused' &&
|
|
105
|
-
'kind' in error
|
|
106
|
-
)
|
|
107
|
-
return {
|
|
108
|
-
status: error.kind === 'expired' ? 'expired' : 'unavailable',
|
|
109
|
-
reason: error.message,
|
|
110
|
-
...('priorActionId' in error && typeof error.priorActionId === 'string'
|
|
111
|
-
? { priorActionId: error.priorActionId }
|
|
112
|
-
: {}),
|
|
113
|
-
};
|
|
167
|
+
const refused = refusalOf(error);
|
|
168
|
+
if (refused) return refused;
|
|
114
169
|
throw error;
|
|
115
170
|
}
|
|
116
|
-
return
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
171
|
+
return preparedResult(reference, ctx);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function requestHubTransfer(
|
|
175
|
+
input: unknown,
|
|
176
|
+
ctx: RuntimeContext,
|
|
177
|
+
): Promise<HubActionRequestResult> {
|
|
178
|
+
const parsed = transferSchema.safeParse(input);
|
|
179
|
+
if (!parsed.success)
|
|
180
|
+
return {
|
|
181
|
+
status: 'unavailable',
|
|
182
|
+
reason:
|
|
183
|
+
'A recipient, a listed token denom and a plain decimal amount are required.',
|
|
184
|
+
};
|
|
185
|
+
if (ctx.config.HUB_ACTIONS_ENABLED !== 'true' || !ctx.hubActions)
|
|
186
|
+
return {
|
|
187
|
+
status: 'unavailable',
|
|
188
|
+
reason: 'Hub actions are not enabled for this oracle.',
|
|
189
|
+
};
|
|
190
|
+
const channel = ctx.session.channel;
|
|
191
|
+
if (
|
|
192
|
+
ctx.session.client !== 'channel' ||
|
|
193
|
+
!channel ||
|
|
194
|
+
!ctx.session.runId ||
|
|
195
|
+
!ctx.toolCallId
|
|
196
|
+
)
|
|
197
|
+
return {
|
|
198
|
+
status: 'unavailable',
|
|
199
|
+
reason: 'A verified channel turn is required.',
|
|
200
|
+
};
|
|
201
|
+
try {
|
|
202
|
+
const reference = await ctx.hubActions.transfer({
|
|
203
|
+
ownerDid: ctx.user.did,
|
|
204
|
+
sessionId: ctx.session.id,
|
|
205
|
+
runId: ctx.session.runId,
|
|
206
|
+
requestId: ctx.session.requestId,
|
|
207
|
+
provider: channel.provider,
|
|
208
|
+
bindingId: channel.bindingId,
|
|
209
|
+
bindingRevision: channel.bindingRevision,
|
|
210
|
+
requestKey: await hubRequestKey(ctx.session.runId, ctx.toolCallId),
|
|
211
|
+
toAddress: parsed.data.toAddress,
|
|
212
|
+
denom: parsed.data.denom,
|
|
213
|
+
amount: parsed.data.amount,
|
|
214
|
+
memo: parsed.data.memo ?? '',
|
|
215
|
+
});
|
|
216
|
+
return preparedResult(reference, ctx);
|
|
217
|
+
} catch (error) {
|
|
218
|
+
// The owner object turns the protocol refusing the operation built
|
|
219
|
+
// from the model's fields into a refusal; anything else is an error.
|
|
220
|
+
const refused = refusalOf(error);
|
|
221
|
+
if (refused) return refused;
|
|
222
|
+
throw error;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** A typed runtime refusal as a result the model can relay, else null. */
|
|
227
|
+
function refusalOf(error: unknown): HubActionRequestResult | null {
|
|
228
|
+
if (
|
|
229
|
+
error instanceof Error &&
|
|
230
|
+
error.name === 'HubActionRefused' &&
|
|
231
|
+
'kind' in error
|
|
232
|
+
)
|
|
233
|
+
return {
|
|
234
|
+
status: error.kind === 'expired' ? 'expired' : 'unavailable',
|
|
235
|
+
reason: error.message,
|
|
236
|
+
...('priorActionId' in error && typeof error.priorActionId === 'string'
|
|
237
|
+
? { priorActionId: error.priorActionId }
|
|
238
|
+
: {}),
|
|
239
|
+
};
|
|
240
|
+
return null;
|
|
121
241
|
}
|
|
122
242
|
|
|
123
243
|
async function hubActionStatus(
|
|
@@ -131,10 +251,11 @@ async function hubActionStatus(
|
|
|
131
251
|
ctx.config.HUB_ACTIONS_ENABLED !== 'true'
|
|
132
252
|
)
|
|
133
253
|
return { status: 'unavailable' };
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
254
|
+
const { outcome, summary } = await ctx.hubActions.status(
|
|
255
|
+
ctx.user.did,
|
|
256
|
+
parsed.data.actionId,
|
|
257
|
+
);
|
|
258
|
+
return { status: 'known', outcome, ...(summary ? { summary } : {}) };
|
|
138
259
|
}
|
|
139
260
|
|
|
140
261
|
async function hubActionCapabilities(
|
|
@@ -218,7 +339,7 @@ export class HubActionsPlugin extends OraclePlugin {
|
|
|
218
339
|
{
|
|
219
340
|
name: 'hub_action_options',
|
|
220
341
|
description:
|
|
221
|
-
'Read this authenticated owner’s confirmed Gmail and Calendar connections, requestable documents and
|
|
342
|
+
'Read this authenticated owner’s confirmed Gmail and Calendar connections, requestable documents, action resources and bank tokens (`transfer.tokens`: denom, symbol, decimals, verified, balance in base units). Use each returned service, identifier and label before preparing a related Hub review. A Gmail profile connection does not authorize sending. For a token that is not verified, name its full denom and say it is unverified; its amounts are base units.',
|
|
222
343
|
schema: discoverSchema,
|
|
223
344
|
effect: 'read',
|
|
224
345
|
handler: hubActionOptions,
|
|
@@ -231,6 +352,14 @@ export class HubActionsPlugin extends OraclePlugin {
|
|
|
231
352
|
effect: 'write',
|
|
232
353
|
handler: requestHubAction,
|
|
233
354
|
},
|
|
355
|
+
{
|
|
356
|
+
name: 'request_hub_transfer',
|
|
357
|
+
description:
|
|
358
|
+
'Prepare a bank transfer of one of the owner’s listed tokens for review in Auth Hub. Never signs or executes. Read hub_action_options first and copy the token `denom` exactly; give the amount as the user said it in that token’s units (no base-unit math, no fee: Auth Hub sets the fee). Name the full denom for anything but IXO, and say when a token is unverified.',
|
|
359
|
+
schema: transferSchema,
|
|
360
|
+
effect: 'write',
|
|
361
|
+
handler: requestHubTransfer,
|
|
362
|
+
},
|
|
234
363
|
{
|
|
235
364
|
name: 'hub_action_status',
|
|
236
365
|
description:
|
|
@@ -12,6 +12,10 @@ export {
|
|
|
12
12
|
type MemoryMcpFactory,
|
|
13
13
|
type UpstreamMcpTool,
|
|
14
14
|
} from './memory-tools';
|
|
15
|
+
export {
|
|
16
|
+
MEMORY_ENTITY_HEADER,
|
|
17
|
+
ORACLE_ENTITY_DID_PATTERN,
|
|
18
|
+
} from './memory-entity';
|
|
15
19
|
export { buildMemoryHeaders } from './memory-ucan';
|
|
16
20
|
export { MemoryPlugin, type MemoryPluginOptions } from './memory.plugin';
|
|
17
21
|
export type * from './types';
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The Memory Engine keys every memory space by the oracle's domain entity
|
|
5
|
+
* (`did:ixo:entity:<32 hex>`): the user's personal space is
|
|
6
|
+
* `sha256(<userDid>-<entityDid>)` and the shared knowledge spaces are the
|
|
7
|
+
* entity's. Every engine request names the entity in this header; the engine
|
|
8
|
+
* refuses a request without it and checks (on chain) that the entity lists
|
|
9
|
+
* the oracle's agent account as a Matrix admin.
|
|
10
|
+
*/
|
|
11
|
+
export const MEMORY_ENTITY_HEADER = 'x-oracle-entity-did';
|
|
12
|
+
|
|
13
|
+
/** The shape the Memory Engine accepts for {@link MEMORY_ENTITY_HEADER}. */
|
|
14
|
+
export const ORACLE_ENTITY_DID_PATTERN = /^did:ixo:entity:[0-9a-f]{32}$/;
|
|
15
|
+
|
|
16
|
+
const ENTITY_REQUIRED_MESSAGE =
|
|
17
|
+
"ORACLE_ENTITY_DID is required by the memory plugin: set it to the oracle's domain entity DID (did:ixo:entity: followed by 32 lower-case hex characters). The Memory Engine keys every memory space by that entity and refuses requests without it.";
|
|
18
|
+
|
|
19
|
+
const ENTITY_SHAPE_MESSAGE =
|
|
20
|
+
"ORACLE_ENTITY_DID must be the oracle's domain entity DID (did:ixo:entity: followed by 32 lower-case hex characters); the Memory Engine refuses any other value.";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* `ORACLE_ENTITY_DID` as the memory plugin requires it. The core schema keeps
|
|
24
|
+
* the variable optional (rooms and feedback fall back to `ORACLE_DID`); the
|
|
25
|
+
* memory plugin redeclares it so an oracle with `MEMORY_MCP_URL` and no
|
|
26
|
+
* entity fails at boot. The engine never receives the `ORACLE_DID` fallback.
|
|
27
|
+
*/
|
|
28
|
+
export const oracleEntityDidSchema = z
|
|
29
|
+
.string({
|
|
30
|
+
error: (issue) =>
|
|
31
|
+
issue.input === undefined
|
|
32
|
+
? ENTITY_REQUIRED_MESSAGE
|
|
33
|
+
: ENTITY_SHAPE_MESSAGE,
|
|
34
|
+
})
|
|
35
|
+
.regex(ORACLE_ENTITY_DID_PATTERN, ENTITY_SHAPE_MESSAGE);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The configured `ORACLE_ENTITY_DID` when it is a well-formed domain entity
|
|
39
|
+
* DID, else `undefined`. For the engine paths outside the plugin (first-turn
|
|
40
|
+
* prefetch, session-history indexing, the memory debug route): they read the
|
|
41
|
+
* merged env directly and run wherever `MEMORY_ENGINE_URL` survives
|
|
42
|
+
* validation, which another plugin than this one may declare, so the
|
|
43
|
+
* plugin's schema does not cover them.
|
|
44
|
+
*/
|
|
45
|
+
export function memoryEntityDid(value: unknown): string | undefined {
|
|
46
|
+
return typeof value === 'string' && ORACLE_ENTITY_DID_PATTERN.test(value)
|
|
47
|
+
? value
|
|
48
|
+
: undefined;
|
|
49
|
+
}
|
|
@@ -28,8 +28,9 @@ export const MEMORY_CLEAR_MCP_NAME = 'memory-engine__clear';
|
|
|
28
28
|
* destructive `clear` stays in the default set. `add_oracle_knowledge` is in
|
|
29
29
|
* the set so the controllers and owners of any oracle can grow its shared
|
|
30
30
|
* knowledge from chat without a per-oracle code change: the Memory Engine
|
|
31
|
-
* itself checks on chain that the caller
|
|
32
|
-
*
|
|
31
|
+
* itself checks on chain that the caller is a member (controller, owner,
|
|
32
|
+
* Matrix admin or DAO member) of the oracle's domain entity and refuses
|
|
33
|
+
* everyone else, so listing the tool for every user
|
|
33
34
|
* costs one tool description and grants nothing. Forks that want a different
|
|
34
35
|
* surface (drop `clear`, add `delete_edge`) pass an explicit list via plugin
|
|
35
36
|
* options.
|
|
@@ -370,7 +371,8 @@ function buildLazyUpstreamTools(
|
|
|
370
371
|
|
|
371
372
|
/**
|
|
372
373
|
* Default factory: authenticates with the per-request UCAN headers from
|
|
373
|
-
* {@link buildMemoryHeaders}
|
|
374
|
+
* {@link buildMemoryHeaders} (which name the oracle's domain entity,
|
|
375
|
+
* `entityDid`), then serves the upstream tool list.
|
|
374
376
|
*
|
|
375
377
|
* The first request per isolate connects and lists tools exactly like the
|
|
376
378
|
* old always-fetch path, then snapshots the definitions. Subsequent requests
|
|
@@ -384,9 +386,10 @@ function buildLazyUpstreamTools(
|
|
|
384
386
|
*/
|
|
385
387
|
export function createDefaultMemoryMcpFactory(
|
|
386
388
|
memoryMcpUrl: string,
|
|
389
|
+
entityDid: string,
|
|
387
390
|
): MemoryMcpFactory {
|
|
388
391
|
return async (runCtx) => {
|
|
389
|
-
const headers = await buildMemoryHeaders(runCtx, memoryMcpUrl);
|
|
392
|
+
const headers = await buildMemoryHeaders(runCtx, memoryMcpUrl, entityDid);
|
|
390
393
|
if (!headers) return null;
|
|
391
394
|
|
|
392
395
|
const cached = toolDefsCache.get(memoryMcpUrl);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { RuntimeContext } from '../../plugin-api/types';
|
|
2
2
|
import { MEMORY_CAPABILITY } from '../delegated-capabilities';
|
|
3
3
|
import { mintInvocationSafely, resolveServiceDidSafely } from '../ucan-failure';
|
|
4
|
+
import { MEMORY_ENTITY_HEADER } from './memory-entity';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Build the headers needed to call the Memory Engine MCP server on behalf of
|
|
@@ -9,7 +10,13 @@ import { mintInvocationSafely, resolveServiceDidSafely } from '../ucan-failure';
|
|
|
9
10
|
* 1. Resolve the memory service DID via `runCtx.ucan.resolveServiceDid`
|
|
10
11
|
* (delegates the did:web lookup + cache to the UCAN service).
|
|
11
12
|
* 2. Mint an `ixo:memory` invocation using the user's signing key.
|
|
12
|
-
* 3. Layer the resulting Bearer token
|
|
13
|
+
* 3. Layer the resulting Bearer token, the oracle's domain entity
|
|
14
|
+
* (`x-oracle-entity-did`, which keys every memory space on the engine)
|
|
15
|
+
* and the active `x-room-id`.
|
|
16
|
+
*
|
|
17
|
+
* `entityDid` is the configured `ORACLE_ENTITY_DID` (validated by the memory
|
|
18
|
+
* plugin's `configSchema`), passed in because `RuntimeContext` carries no
|
|
19
|
+
* oracle identity. It is never the core's `ORACLE_DID` fallback.
|
|
13
20
|
*
|
|
14
21
|
* UCAN-only — no Matrix-OpenID fallback. If resolution or minting fails for
|
|
15
22
|
* any reason (no signing key, no cached delegation, did:web unresolved,
|
|
@@ -22,6 +29,7 @@ import { mintInvocationSafely, resolveServiceDidSafely } from '../ucan-failure';
|
|
|
22
29
|
export async function buildMemoryHeaders(
|
|
23
30
|
runCtx: RuntimeContext,
|
|
24
31
|
memoryMcpUrl: string,
|
|
32
|
+
entityDid: string,
|
|
25
33
|
): Promise<Record<string, string> | null> {
|
|
26
34
|
const memoryDid = await resolveServiceDidSafely(
|
|
27
35
|
runCtx,
|
|
@@ -44,6 +52,7 @@ export async function buildMemoryHeaders(
|
|
|
44
52
|
Authorization: `Bearer ${invocation}`,
|
|
45
53
|
'X-Auth-Type': 'ucan',
|
|
46
54
|
'User-Agent': 'LangChain-MCP-Client/1.0',
|
|
55
|
+
[MEMORY_ENTITY_HEADER]: entityDid,
|
|
47
56
|
};
|
|
48
57
|
if (runCtx.session.roomId) {
|
|
49
58
|
headers['x-room-id'] = runCtx.session.roomId;
|