@keemakr/agent-sdk 0.6.0 → 0.8.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 +76 -26
- package/dist/client.d.ts +53 -3
- package/dist/client.js +115 -26
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/refresh.d.ts +31 -0
- package/dist/refresh.js +112 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -19,11 +19,11 @@ Peer dependencies (match your eve agent): `eve@0.13.0`, `jose@^6.2.3`.
|
|
|
19
19
|
|
|
20
20
|
Set these in your deployed agent's environment:
|
|
21
21
|
|
|
22
|
-
| Variable
|
|
23
|
-
|
|
24
|
-
| `KEE_CORE_JWKS_URL`
|
|
22
|
+
| Variable | Purpose |
|
|
23
|
+
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
|
24
|
+
| `KEE_CORE_JWKS_URL` | keemakr-core's JWKS endpoint, e.g. `https://app.keemakr.com/.well-known/jwks.json`. Enables grant verification. |
|
|
25
25
|
| `KEE_AGENT_AUDIENCE` | This deployment's audience — your runtime URL's origin, e.g. `https://my-agent.example.com`. Must match the audience the operator mints. |
|
|
26
|
-
| `KEE_CORE_URL`
|
|
26
|
+
| `KEE_CORE_URL` | keemakr-core's base URL for capability calls, e.g. `https://app.keemakr.com`. (Derived from `KEE_CORE_JWKS_URL` if unset.) |
|
|
27
27
|
|
|
28
28
|
If `KEE_CORE_JWKS_URL` is unset, `grantAuth()` skips entirely — useful during local development.
|
|
29
29
|
|
|
@@ -32,9 +32,9 @@ If `KEE_CORE_JWKS_URL` is unset, `grantAuth()` skips entirely — useful during
|
|
|
32
32
|
`grantAuth()` returns an eve `AuthFn`. Put it ahead of any fallback:
|
|
33
33
|
|
|
34
34
|
```ts title="agent/channels/eve.ts"
|
|
35
|
-
import { localDev, vercelOidc } from
|
|
36
|
-
import { eveChannel } from
|
|
37
|
-
import { grantAuth } from
|
|
35
|
+
import { localDev, vercelOidc } from 'eve/channels/auth';
|
|
36
|
+
import { eveChannel } from 'eve/channels/eve';
|
|
37
|
+
import { grantAuth } from '@keemakr/agent-sdk';
|
|
38
38
|
|
|
39
39
|
export default eveChannel({
|
|
40
40
|
auth: [localDev(), vercelOidc(), grantAuth()],
|
|
@@ -46,9 +46,9 @@ On success the verified tenant id and scopes are attached to the session auth co
|
|
|
46
46
|
## 2. Reach tenant data from a tool
|
|
47
47
|
|
|
48
48
|
```ts title="agent/tools/find_email.ts"
|
|
49
|
-
import { defineTool } from
|
|
50
|
-
import { z } from
|
|
51
|
-
import { useKee } from
|
|
49
|
+
import { defineTool } from 'eve/tools';
|
|
50
|
+
import { z } from 'zod';
|
|
51
|
+
import { useKee } from '@keemakr/agent-sdk';
|
|
52
52
|
|
|
53
53
|
export default defineTool({
|
|
54
54
|
description: "Find a lead's work email.",
|
|
@@ -60,7 +60,7 @@ export default defineTool({
|
|
|
60
60
|
async execute(args, ctx) {
|
|
61
61
|
const kee = useKee(ctx);
|
|
62
62
|
// Proxy path: the credential stays in keemakr-core; you get the result.
|
|
63
|
-
const result = await kee.connections.hunter.call(
|
|
63
|
+
const result = await kee.connections.hunter.call('email-finder', args);
|
|
64
64
|
return result; // { email, score, status }
|
|
65
65
|
},
|
|
66
66
|
});
|
|
@@ -87,12 +87,12 @@ const { access_token } = await kee.connections.hunter.token();
|
|
|
87
87
|
`@keemakr/agent-sdk/connectors` ships a generated, typed manifest of every connector keemakr-core exposes — provider slugs, `maturity`, and each operation's name + JSON-Schema arg contract. Use it to discover what's callable (and get autocomplete on provider + op names) **without** scanning a core checkout or hitting a running instance. It's **metadata only** — no credentials.
|
|
88
88
|
|
|
89
89
|
```ts
|
|
90
|
-
import { connectors, opNames, isReady } from
|
|
90
|
+
import { connectors, opNames, isReady } from '@keemakr/agent-sdk/connectors';
|
|
91
91
|
|
|
92
|
-
opNames(
|
|
93
|
-
connectors.hunter.ops[
|
|
94
|
-
isReady(
|
|
95
|
-
connectors.meta.maturity;
|
|
92
|
+
opNames('hunter'); // → ["email-finder"]
|
|
93
|
+
connectors.hunter.ops['email-finder'].inputSchema; // JSON Schema for the args
|
|
94
|
+
isReady('meta'); // false while a connector is coming_soon
|
|
95
|
+
connectors.meta.maturity; // "coming_soon" | "ready"
|
|
96
96
|
```
|
|
97
97
|
|
|
98
98
|
A `coming_soon` connector is declarable in your `entry.json` `dependencies` today; its operations start callable (and `isReady` flips to `true`) once core ships them — **no change to your agent**.
|
|
@@ -107,33 +107,83 @@ npm run gen:connectors # or: npm run build (runs gen first)
|
|
|
107
107
|
### Memory (cross-session, tenant-shared)
|
|
108
108
|
|
|
109
109
|
```ts
|
|
110
|
-
await kee.memory.set(
|
|
111
|
-
await kee.memory.get(
|
|
112
|
-
await kee.memory.list(
|
|
113
|
-
await kee.memory.delete(
|
|
110
|
+
await kee.memory.set('prefs', 'tone', { tone: 'formal' });
|
|
111
|
+
await kee.memory.get('prefs', 'tone'); // → { tone: "formal" }
|
|
112
|
+
await kee.memory.list('prefs'); // → entries in the namespace
|
|
113
|
+
await kee.memory.delete('prefs', 'tone');
|
|
114
114
|
// Semantic search by meaning (embeddings):
|
|
115
|
-
const hits = await kee.memory.search(
|
|
115
|
+
const hits = await kee.memory.search('how should I speak to the user?', { limit: 5 });
|
|
116
116
|
// → [{ namespace, key, value, score, … }] (score 0–1, nearest first)
|
|
117
117
|
```
|
|
118
118
|
|
|
119
|
+
Memory is tenant-shared: any of the tenant's installed agents can read/write any
|
|
120
|
+
namespace. Concurrent writers should take turns — every entry carries a
|
|
121
|
+
monotonic `version`, and conditional writes lose gracefully instead of
|
|
122
|
+
clobbering:
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
const entry = await kee.memory.getEntry('crm', 'lead:acme'); // { value, version, … }
|
|
126
|
+
try {
|
|
127
|
+
await kee.memory.set('crm', 'lead:acme', next, { ifVersion: entry!.version });
|
|
128
|
+
} catch (e) {
|
|
129
|
+
if (e instanceof MemoryConflictError) {
|
|
130
|
+
// someone wrote first — e.current is the winning entry; re-read, re-derive, retry
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// Or merge one field with no read at all (object values only):
|
|
134
|
+
await kee.memory.patch('crm', 'lead:acme', { status: 'contacted' });
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Use memory for your agent's own continuity (preferences, cursors, entity
|
|
138
|
+
state) — durable documents belong in the tenant knowledge base, which you read
|
|
139
|
+
via `kee.kb`.
|
|
140
|
+
|
|
141
|
+
### Knowledge base (read-only retrieval)
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
const hits = await kee.kb.search('what is our refund policy?', { k: 5 });
|
|
145
|
+
// → [{ text, score, provenance: { title, source_uri, … } }]
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Scoped server-side to the collections bound to your agent + the tenant's
|
|
149
|
+
default corpus + the shared platform KB (`kb:retrieve` scope, granted to every
|
|
150
|
+
install). Hybrid retrieval, reranked in core; `text` may be a wider parent
|
|
151
|
+
context for clause-level documents.
|
|
152
|
+
|
|
119
153
|
### Platform tools
|
|
120
154
|
|
|
121
155
|
```ts
|
|
122
|
-
await kee.tools.list();
|
|
123
|
-
await kee.tools.run(
|
|
156
|
+
await kee.tools.list(); // tools this grant is entitled to
|
|
157
|
+
await kee.tools.run('current-time'); // run one in keemakr-core
|
|
124
158
|
```
|
|
125
159
|
|
|
126
160
|
A call whose grant lacks the required scope returns a `KeeError` with `status: 403`; an expired/invalid grant returns `status: 401`.
|
|
127
161
|
|
|
162
|
+
## Grant refresh (automatic since 0.8.0)
|
|
163
|
+
|
|
164
|
+
Every `useKee` capability call keeps its grant alive by itself: when the active token has under two minutes left, the SDK exchanges it at core's `POST /api/capability/grant/refresh` (single-flight per delegation — concurrent tool calls share one refresh), and a `401 grant_expired` gets one refresh + one retry before surfacing. You write nothing; long runs simply stop dying at the TTL.
|
|
165
|
+
|
|
166
|
+
The floor is core's, not the SDK's: an **expired** grant can never be refreshed, scopes are re-derived from the install at each exchange, and the whole chain dies at the renewal horizon (`CAPABILITY_GRANT_MAX_LIFETIME_SECONDS` on core, default 6h) with a relayable `grant_horizon_exceeded` error.
|
|
167
|
+
|
|
168
|
+
Headless callers holding a raw grant can drive the exchange directly:
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
import { refreshGrant } from '@keemakr/agent-sdk';
|
|
172
|
+
|
|
173
|
+
const { token, exp } = await refreshGrant(currentToken); // throws KeeError when core refuses
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
`refreshGrant` resolves core from `KEE_CORE_URL` / `KEE_CORE_JWKS_URL` (pass `{ coreUrl }` to override) and never verifies or signs anything locally — core is the only judge.
|
|
177
|
+
|
|
128
178
|
## Autonomous / scheduled runs
|
|
129
179
|
|
|
130
180
|
A cron/scheduled turn has no operator session, so it gets no session grant. keemakr-core can mint a **machine grant** for it (gated on the tenant's per-install `unattended_consent`). If your remote runs **outside** an eve channel, verify that grant directly:
|
|
131
181
|
|
|
132
182
|
```ts
|
|
133
|
-
import { verifyGrant } from
|
|
183
|
+
import { verifyGrant } from '@keemakr/agent-sdk';
|
|
134
184
|
|
|
135
185
|
const claims = await verifyGrant(grantToken, { audience: process.env.KEE_AGENT_AUDIENCE });
|
|
136
|
-
if (!claims) throw new Error(
|
|
186
|
+
if (!claims) throw new Error('invalid or expired grant');
|
|
137
187
|
// claims.tenantId, claims.scopes, claims.aud, claims.exp
|
|
138
188
|
```
|
|
139
189
|
|
|
@@ -143,7 +193,7 @@ Inside an eve channel, `grantAuth()` already accepts machine grants (same token
|
|
|
143
193
|
|
|
144
194
|
- **Tenant/service credentials live in keemakr-core**, reached only via the proxy — the credential never crosses the wire. Tenant is always the verified grant, resolved server-side; never pass a tenant id from tool input.
|
|
145
195
|
- **The token path is opt-in and scope-gated** (`conn:<provider>:token`), declared per dependency in `entry.json` (`"access": "token"`).
|
|
146
|
-
- **You MAY hold your own model key.** There is no platform model gateway today, so an agent routing its own LLM calls (its own Anthropic/AI-Gateway key) is expected and fine — that is
|
|
196
|
+
- **You MAY hold your own model key.** There is no platform model gateway today, so an agent routing its own LLM calls (its own Anthropic/AI-Gateway key) is expected and fine — that is _not_ a credential leak. A leak is a _tenant/service_ credential read in agent code.
|
|
147
197
|
- Every capability call re-verifies the grant and enforces scope on the server.
|
|
148
198
|
|
|
149
199
|
Full contract: keemakr-core `docs/CONNECTOR-CONTRACT.md`.
|
package/dist/client.d.ts
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
export interface KeeError extends Error {
|
|
2
2
|
status?: number;
|
|
3
|
+
/** Parsed error-response body, when core sent one (e.g. the conflicting entry on 409). */
|
|
4
|
+
body?: unknown;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* A conditional memory write (`ifVersion`) lost the race — another agent wrote
|
|
8
|
+
* the key first. `current` is the entry as it now stands (null when the key was
|
|
9
|
+
* deleted concurrently). Re-read, re-derive, retry.
|
|
10
|
+
*/
|
|
11
|
+
export declare class MemoryConflictError extends Error {
|
|
12
|
+
status: 409;
|
|
13
|
+
current: MemoryEntry | null;
|
|
14
|
+
constructor(current: MemoryEntry | null);
|
|
3
15
|
}
|
|
4
16
|
export interface KeeContext {
|
|
5
17
|
session?: {
|
|
@@ -26,6 +38,8 @@ export interface MemoryEntry {
|
|
|
26
38
|
key: string;
|
|
27
39
|
value: unknown;
|
|
28
40
|
written_by_agent: string | null;
|
|
41
|
+
/** Monotonic write counter — pass as `ifVersion` for compare-and-swap writes. */
|
|
42
|
+
version: number;
|
|
29
43
|
created_at: string;
|
|
30
44
|
updated_at: string;
|
|
31
45
|
}
|
|
@@ -43,10 +57,25 @@ export interface MemorySearchHit extends MemoryEntry {
|
|
|
43
57
|
export interface KeeMemory {
|
|
44
58
|
/** Read a key's value, or null if absent. */
|
|
45
59
|
get(namespace: string, key: string): Promise<unknown | null>;
|
|
46
|
-
/** Read the full entry (value + provenance + timestamps), or null. */
|
|
60
|
+
/** Read the full entry (value + provenance + version + timestamps), or null. */
|
|
47
61
|
getEntry(namespace: string, key: string): Promise<MemoryEntry | null>;
|
|
48
|
-
/**
|
|
49
|
-
|
|
62
|
+
/**
|
|
63
|
+
* Write a key. Returns the stored entry. Pass `ifVersion` (from a prior
|
|
64
|
+
* getEntry) to make it a compare-and-swap: throws MemoryConflictError when
|
|
65
|
+
* another agent wrote the key in between.
|
|
66
|
+
*/
|
|
67
|
+
set(namespace: string, key: string, value: unknown, opts?: {
|
|
68
|
+
ifVersion?: number;
|
|
69
|
+
}): Promise<MemoryEntry>;
|
|
70
|
+
/**
|
|
71
|
+
* Shallow-merge `delta` into an existing object value without reading it
|
|
72
|
+
* first — safe under concurrency for the "add one field" case. Throws
|
|
73
|
+
* MemoryConflictError on an `ifVersion` mismatch; a KeeError with status 404
|
|
74
|
+
* when the key is absent, 422 when the stored value isn't an object.
|
|
75
|
+
*/
|
|
76
|
+
patch(namespace: string, key: string, delta: Record<string, unknown>, opts?: {
|
|
77
|
+
ifVersion?: number;
|
|
78
|
+
}): Promise<MemoryEntry>;
|
|
50
79
|
/** Delete a key. Returns whether it existed. */
|
|
51
80
|
delete(namespace: string, key: string): Promise<boolean>;
|
|
52
81
|
/** List every entry in a namespace (tenant-wide). */
|
|
@@ -57,6 +86,26 @@ export interface KeeMemory {
|
|
|
57
86
|
limit?: number;
|
|
58
87
|
}): Promise<MemorySearchHit[]>;
|
|
59
88
|
}
|
|
89
|
+
/** One KB retrieval hit. `text` is the chunk (or its parent context for
|
|
90
|
+
* parent-child-chunked documents); `score` is the reranker's relevance when
|
|
91
|
+
* reranking ran, else the RRF fusion score. */
|
|
92
|
+
export interface KBHit {
|
|
93
|
+
text: string;
|
|
94
|
+
score: number;
|
|
95
|
+
provenance: Record<string, unknown>;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Tenant knowledge-base retrieval. The agent sees the collections bound to it
|
|
99
|
+
* plus the tenant's default corpus plus the shared platform KB — scoping is
|
|
100
|
+
* enforced server-side from the grant. Requires the `kb:retrieve` scope
|
|
101
|
+
* (granted to every install).
|
|
102
|
+
*/
|
|
103
|
+
export interface KeeKb {
|
|
104
|
+
/** Semantic + lexical + reranked search over the agent-visible knowledge. */
|
|
105
|
+
search(query: string, opts?: {
|
|
106
|
+
k?: number;
|
|
107
|
+
}): Promise<KBHit[]>;
|
|
108
|
+
}
|
|
60
109
|
/** Platform registry tools (Shape B) — defined in core, run server-side. */
|
|
61
110
|
export interface KeeTools {
|
|
62
111
|
/** List the registry tools this grant is entitled to. */
|
|
@@ -76,6 +125,7 @@ export interface Kee {
|
|
|
76
125
|
get(provider: string): KeeConnection;
|
|
77
126
|
};
|
|
78
127
|
memory: KeeMemory;
|
|
128
|
+
kb: KeeKb;
|
|
79
129
|
tools: KeeTools;
|
|
80
130
|
}
|
|
81
131
|
/**
|
package/dist/client.js
CHANGED
|
@@ -4,12 +4,33 @@
|
|
|
4
4
|
// /api/capability/* endpoints, forwarding the grant. Core re-verifies the grant
|
|
5
5
|
// and enforces scope on every call; the SDK never sees a raw credential on the
|
|
6
6
|
// proxy path.
|
|
7
|
-
|
|
7
|
+
//
|
|
8
|
+
// Since 0.8.0 every capability call keeps its grant ALIVE transparently: the
|
|
9
|
+
// token is refreshed shortly before expiry (see refresh.ts), and a 401
|
|
10
|
+
// `grant_expired` gets one refresh + one retry before surfacing. Long runs stop
|
|
11
|
+
// dying mid-flight; the ceiling is core's renewal horizon (default 6h).
|
|
12
|
+
import { ensureFreshToken, tokenForRetry, coreBaseUrl } from './refresh.js';
|
|
13
|
+
function keeError(message, status, body) {
|
|
8
14
|
const e = new Error(message);
|
|
9
15
|
e.name = 'KeeError';
|
|
10
16
|
e.status = status;
|
|
17
|
+
e.body = body;
|
|
11
18
|
return e;
|
|
12
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* A conditional memory write (`ifVersion`) lost the race — another agent wrote
|
|
22
|
+
* the key first. `current` is the entry as it now stands (null when the key was
|
|
23
|
+
* deleted concurrently). Re-read, re-derive, retry.
|
|
24
|
+
*/
|
|
25
|
+
export class MemoryConflictError extends Error {
|
|
26
|
+
status = 409;
|
|
27
|
+
current;
|
|
28
|
+
constructor(current) {
|
|
29
|
+
super('memory version conflict — the key changed since it was read');
|
|
30
|
+
this.name = 'MemoryConflictError';
|
|
31
|
+
this.current = current;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
13
34
|
function readGrant(ctx) {
|
|
14
35
|
const attrs = ctx?.session?.auth?.current?.attributes ?? {};
|
|
15
36
|
const token = typeof attrs.grant_token === 'string' ? attrs.grant_token : undefined;
|
|
@@ -21,32 +42,69 @@ function readGrant(ctx) {
|
|
|
21
42
|
}
|
|
22
43
|
return { token, tenantId, scopes, traceId };
|
|
23
44
|
}
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
45
|
+
// A 401 from core surfaces INSIDE the remote agent's run as a failed tool, and
|
|
46
|
+
// the only audience there is the remote's model — so the message must carry
|
|
47
|
+
// remediation the model can relay verbatim instead of improvising around it.
|
|
48
|
+
// Branches on core's typed `code` when present; the /expired/ text test remains
|
|
49
|
+
// the fallback for older cores' undifferentiated 'invalid or expired grant'.
|
|
50
|
+
function with401Guidance(base, code) {
|
|
51
|
+
if (code === 'grant_horizon_exceeded') {
|
|
52
|
+
return (`${base} — the delegation ran past the platform's renewal horizon ` +
|
|
53
|
+
`(CAPABILITY_GRANT_MAX_LIFETIME_SECONDS, default 6h). This is not an ` +
|
|
54
|
+
`agent-fixable error: split the work into shorter runs, or the platform ` +
|
|
55
|
+
`operator can raise the horizon deliberately.`);
|
|
56
|
+
}
|
|
57
|
+
if (code === 'grant_expired' || (!code && /expired/i.test(base))) {
|
|
58
|
+
return (`${base} — the capability grant expired mid-run and could not be refreshed ` +
|
|
59
|
+
`(the SDK refreshes automatically before expiry). This is not an agent-fixable ` +
|
|
60
|
+
`error: tell the operator to retry the delegation once; if it recurs, the ` +
|
|
61
|
+
`keemakr-core deployment may predate the grant-refresh endpoint — raise ` +
|
|
62
|
+
`CAPABILITY_GRANT_TTL_SECONDS there, or use a machine grant for long ` +
|
|
63
|
+
`unattended work.`);
|
|
64
|
+
}
|
|
65
|
+
return (`${base} — the capability grant was rejected. This is not an agent-fixable ` +
|
|
66
|
+
`error: the operator should re-issue the delegation.`);
|
|
67
|
+
}
|
|
68
|
+
/** An expired-grant denial: the typed `code` from current cores, with the
|
|
69
|
+
* legacy error-text match as the fallback against pre-refresh cores. */
|
|
70
|
+
function isExpiredDenial(status, body) {
|
|
71
|
+
if (status !== 401)
|
|
72
|
+
return false;
|
|
73
|
+
if (body.code)
|
|
74
|
+
return body.code === 'grant_expired';
|
|
75
|
+
return /expired/i.test(body.error ?? '');
|
|
34
76
|
}
|
|
35
77
|
async function capabilityFetch(grant, path, body, method = 'POST') {
|
|
36
78
|
const url = `${coreBaseUrl()}/api/capability/${path}`;
|
|
37
79
|
const hasBody = method !== 'GET' && method !== 'DELETE';
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
80
|
+
const send = async (token) => {
|
|
81
|
+
const res = await fetch(url, {
|
|
82
|
+
method,
|
|
83
|
+
headers: {
|
|
84
|
+
...(hasBody ? { 'content-type': 'application/json' } : {}),
|
|
85
|
+
authorization: `Bearer ${token}`,
|
|
86
|
+
...(grant.traceId ? { 'x-keemakr-trace-id': grant.traceId } : {}),
|
|
87
|
+
},
|
|
88
|
+
...(hasBody ? { body: JSON.stringify(body ?? {}) } : {}),
|
|
89
|
+
});
|
|
90
|
+
const json = (await res.json().catch(() => ({})));
|
|
91
|
+
return { res, json };
|
|
92
|
+
};
|
|
93
|
+
// Proactive: refresh the grant first when it's inside the expiry threshold
|
|
94
|
+
// (failure degrades to the current token — core is the judge).
|
|
95
|
+
const token = await ensureFreshToken(grant.token);
|
|
96
|
+
let { res, json } = await send(token);
|
|
97
|
+
// Reactive: one refresh + one retry when core says the grant expired in
|
|
98
|
+
// flight (or another call refreshed while this one was out on a stale
|
|
99
|
+
// token). At most one retry — never a loop.
|
|
100
|
+
if (isExpiredDenial(res.status, json)) {
|
|
101
|
+
const retryToken = await tokenForRetry(grant.token, token);
|
|
102
|
+
if (retryToken)
|
|
103
|
+
({ res, json } = await send(retryToken));
|
|
104
|
+
}
|
|
48
105
|
if (!res.ok) {
|
|
49
|
-
|
|
106
|
+
const base = json.error ?? `capability request failed (${res.status})`;
|
|
107
|
+
throw keeError(res.status === 401 ? with401Guidance(base, json.code) : base, res.status, json);
|
|
50
108
|
}
|
|
51
109
|
return json;
|
|
52
110
|
}
|
|
@@ -92,9 +150,31 @@ export function useKee(ctx) {
|
|
|
92
150
|
const entry = await this.getEntry(namespace, key);
|
|
93
151
|
return entry ? entry.value : null;
|
|
94
152
|
},
|
|
95
|
-
async set(namespace, key, value) {
|
|
96
|
-
|
|
97
|
-
|
|
153
|
+
async set(namespace, key, value, opts) {
|
|
154
|
+
try {
|
|
155
|
+
const json = (await capabilityFetch(grant, `memory/${enc(namespace)}/${enc(key)}`, { value, expected_version: opts?.ifVersion }, 'PUT'));
|
|
156
|
+
return json.entry;
|
|
157
|
+
}
|
|
158
|
+
catch (e) {
|
|
159
|
+
if (e.status === 409) {
|
|
160
|
+
const body = e.body;
|
|
161
|
+
throw new MemoryConflictError(body?.entry ?? null);
|
|
162
|
+
}
|
|
163
|
+
throw e;
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
async patch(namespace, key, delta, opts) {
|
|
167
|
+
try {
|
|
168
|
+
const json = (await capabilityFetch(grant, `memory/${enc(namespace)}/${enc(key)}`, { delta, expected_version: opts?.ifVersion }, 'PATCH'));
|
|
169
|
+
return json.entry;
|
|
170
|
+
}
|
|
171
|
+
catch (e) {
|
|
172
|
+
if (e.status === 409) {
|
|
173
|
+
const body = e.body;
|
|
174
|
+
throw new MemoryConflictError(body?.entry ?? null);
|
|
175
|
+
}
|
|
176
|
+
throw e;
|
|
177
|
+
}
|
|
98
178
|
},
|
|
99
179
|
async delete(namespace, key) {
|
|
100
180
|
const json = (await capabilityFetch(grant, `memory/${enc(namespace)}/${enc(key)}`, undefined, 'DELETE'));
|
|
@@ -113,6 +193,15 @@ export function useKee(ctx) {
|
|
|
113
193
|
return json.hits ?? [];
|
|
114
194
|
},
|
|
115
195
|
};
|
|
196
|
+
const kb = {
|
|
197
|
+
async search(query, opts) {
|
|
198
|
+
const json = (await capabilityFetch(grant, 'kb/retrieve', {
|
|
199
|
+
query,
|
|
200
|
+
k: opts?.k,
|
|
201
|
+
}));
|
|
202
|
+
return json.hits ?? [];
|
|
203
|
+
},
|
|
204
|
+
};
|
|
116
205
|
const tools = {
|
|
117
206
|
async list() {
|
|
118
207
|
const json = (await capabilityFetch(grant, 'tools', undefined, 'GET'));
|
|
@@ -125,5 +214,5 @@ export function useKee(ctx) {
|
|
|
125
214
|
return json.result;
|
|
126
215
|
},
|
|
127
216
|
};
|
|
128
|
-
return { tenantId: grant.tenantId, scopes: grant.scopes, connections, memory, tools };
|
|
217
|
+
return { tenantId: grant.tenantId, scopes: grant.scopes, connections, memory, kb, tools };
|
|
129
218
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { grantAuth } from './grant-auth.js';
|
|
2
2
|
export { verifyGrant, type VerifiedGrant } from './verify-grant.js';
|
|
3
|
-
export { useKee, type Kee, type KeeConnection, type KeeContext, type KeeError, type KeeMemory, type KeeTools, type MemoryEntry, type MemorySearchHit, } from './client.js';
|
|
3
|
+
export { useKee, MemoryConflictError, type Kee, type KeeConnection, type KeeContext, type KeeError, type KeeMemory, type KeeKb, type KBHit, type KeeTools, type MemoryEntry, type MemorySearchHit, } from './client.js';
|
|
4
4
|
export { keemakrToolDirectory } from './tool-directory.js';
|
|
5
|
+
export { refreshGrant, REFRESH_THRESHOLD_SECONDS } from './refresh.js';
|
package/dist/index.js
CHANGED
|
@@ -10,5 +10,6 @@
|
|
|
10
10
|
// const r = await kee.connections.hunter.call('email-finder', { domain, first_name, last_name });
|
|
11
11
|
export { grantAuth } from './grant-auth.js';
|
|
12
12
|
export { verifyGrant } from './verify-grant.js';
|
|
13
|
-
export { useKee, } from './client.js';
|
|
13
|
+
export { useKee, MemoryConflictError, } from './client.js';
|
|
14
14
|
export { keemakrToolDirectory } from './tool-directory.js';
|
|
15
|
+
export { refreshGrant, REFRESH_THRESHOLD_SECONDS } from './refresh.js';
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Refresh when the active token has less than this long left to live. */
|
|
2
|
+
export declare const REFRESH_THRESHOLD_SECONDS = 120;
|
|
3
|
+
/** Resolve core's base URL: KEE_CORE_URL, else derived from KEE_CORE_JWKS_URL. */
|
|
4
|
+
export declare function coreBaseUrl(): string;
|
|
5
|
+
/** The freshest token known for a delegation (the exchanged one, else the original). */
|
|
6
|
+
export declare function activeGrantToken(originalToken: string): string;
|
|
7
|
+
/**
|
|
8
|
+
* Exchange a still-valid grant for a fresh one. The one public low-level hook,
|
|
9
|
+
* for headless/advanced callers — `useKee` calls it for you. Throws a KeeError-
|
|
10
|
+
* shaped error when core refuses (expired grant, horizon exceeded, …); the
|
|
11
|
+
* error's `body.code` carries core's machine-readable reason.
|
|
12
|
+
*/
|
|
13
|
+
export declare function refreshGrant(token: string, opts?: {
|
|
14
|
+
coreUrl?: string;
|
|
15
|
+
}): Promise<{
|
|
16
|
+
token: string;
|
|
17
|
+
exp: number;
|
|
18
|
+
}>;
|
|
19
|
+
/**
|
|
20
|
+
* The proactive path, called before every capability request: returns the token
|
|
21
|
+
* the request should carry, refreshing first when the active one is inside the
|
|
22
|
+
* expiry threshold. A failed refresh degrades to the current token.
|
|
23
|
+
*/
|
|
24
|
+
export declare function ensureFreshToken(originalToken: string): Promise<string>;
|
|
25
|
+
/**
|
|
26
|
+
* The reactive path, called once after a 401 `grant_expired`: if another call
|
|
27
|
+
* already refreshed (the request went out on a stale token), hand back the
|
|
28
|
+
* newer one; otherwise attempt one shared refresh. Returns the token to retry
|
|
29
|
+
* with, or null — the caller retries AT MOST once and never loops.
|
|
30
|
+
*/
|
|
31
|
+
export declare function tokenForRetry(originalToken: string, usedToken: string): Promise<string | null>;
|
package/dist/refresh.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Grant refresh — keeps a delegation's capability grant alive for the whole
|
|
2
|
+
// run by exchanging a STILL-VALID grant for a fresh one at core's
|
|
3
|
+
// POST /api/capability/grant/refresh (sliding renewal; the chain is bounded
|
|
4
|
+
// server-side by root_iat, default 6h).
|
|
5
|
+
//
|
|
6
|
+
// The cache is MODULE state keyed by the ORIGINAL grant token from the session
|
|
7
|
+
// auth attributes — deliberately not closure state: eve step replay
|
|
8
|
+
// reconstructs closures, and a closure-held fresh token would silently vanish
|
|
9
|
+
// on replay. Module state survives within a process; across processes the
|
|
10
|
+
// worst case is one redundant refresh, which core guarantees is harmless
|
|
11
|
+
// (refreshing the same grant twice just yields two valid tokens).
|
|
12
|
+
//
|
|
13
|
+
// The SDK never verifies signatures and never sees the signing key —
|
|
14
|
+
// decodeJwt() here reads `exp` locally only; core re-verifies everything.
|
|
15
|
+
import { decodeJwt } from 'jose';
|
|
16
|
+
/** Refresh when the active token has less than this long left to live. */
|
|
17
|
+
export const REFRESH_THRESHOLD_SECONDS = 120;
|
|
18
|
+
// original grant token → freshest exchanged token. Module-level on purpose (R2).
|
|
19
|
+
const refreshed = new Map();
|
|
20
|
+
// original grant token → in-flight refresh, so N concurrent tool calls share one POST.
|
|
21
|
+
const inflight = new Map();
|
|
22
|
+
/** Resolve core's base URL: KEE_CORE_URL, else derived from KEE_CORE_JWKS_URL. */
|
|
23
|
+
export function coreBaseUrl() {
|
|
24
|
+
const explicit = process.env.KEE_CORE_URL;
|
|
25
|
+
if (explicit)
|
|
26
|
+
return explicit.replace(/\/$/, '');
|
|
27
|
+
const jwks = process.env.KEE_CORE_JWKS_URL;
|
|
28
|
+
if (jwks)
|
|
29
|
+
return jwks.replace(/\/\.well-known\/jwks\.json\/?$/, '');
|
|
30
|
+
const e = new Error('KEE_CORE_URL (or KEE_CORE_JWKS_URL) must be set to reach the Capability API');
|
|
31
|
+
e.name = 'KeeError';
|
|
32
|
+
throw e;
|
|
33
|
+
}
|
|
34
|
+
function expOf(token) {
|
|
35
|
+
try {
|
|
36
|
+
const { exp } = decodeJwt(token);
|
|
37
|
+
return typeof exp === 'number' ? exp : null;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** The freshest token known for a delegation (the exchanged one, else the original). */
|
|
44
|
+
export function activeGrantToken(originalToken) {
|
|
45
|
+
return refreshed.get(originalToken)?.token ?? originalToken;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Exchange a still-valid grant for a fresh one. The one public low-level hook,
|
|
49
|
+
* for headless/advanced callers — `useKee` calls it for you. Throws a KeeError-
|
|
50
|
+
* shaped error when core refuses (expired grant, horizon exceeded, …); the
|
|
51
|
+
* error's `body.code` carries core's machine-readable reason.
|
|
52
|
+
*/
|
|
53
|
+
export async function refreshGrant(token, opts) {
|
|
54
|
+
const base = opts?.coreUrl?.replace(/\/$/, '') ?? coreBaseUrl();
|
|
55
|
+
const res = await fetch(`${base}/api/capability/grant/refresh`, {
|
|
56
|
+
method: 'POST',
|
|
57
|
+
headers: { authorization: `Bearer ${token}` },
|
|
58
|
+
});
|
|
59
|
+
const json = (await res.json().catch(() => ({})));
|
|
60
|
+
if (!res.ok || typeof json.token !== 'string' || typeof json.exp !== 'number') {
|
|
61
|
+
const e = new Error(`grant refresh failed (${res.status})${json.error ? `: ${json.error}` : ''}`);
|
|
62
|
+
e.name = 'KeeError';
|
|
63
|
+
e.status = res.status;
|
|
64
|
+
e.body = json;
|
|
65
|
+
throw e;
|
|
66
|
+
}
|
|
67
|
+
return { token: json.token, exp: json.exp };
|
|
68
|
+
}
|
|
69
|
+
/** Single-flight refresh of a delegation's ACTIVE token; null on any failure. */
|
|
70
|
+
function refreshShared(originalToken) {
|
|
71
|
+
const running = inflight.get(originalToken);
|
|
72
|
+
if (running)
|
|
73
|
+
return running;
|
|
74
|
+
const attempt = refreshGrant(activeGrantToken(originalToken))
|
|
75
|
+
.then((fresh) => {
|
|
76
|
+
refreshed.set(originalToken, fresh);
|
|
77
|
+
return fresh;
|
|
78
|
+
})
|
|
79
|
+
.catch(() => null) // non-fatal: proceed on the current token, core decides
|
|
80
|
+
.finally(() => {
|
|
81
|
+
inflight.delete(originalToken);
|
|
82
|
+
});
|
|
83
|
+
inflight.set(originalToken, attempt);
|
|
84
|
+
return attempt;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The proactive path, called before every capability request: returns the token
|
|
88
|
+
* the request should carry, refreshing first when the active one is inside the
|
|
89
|
+
* expiry threshold. A failed refresh degrades to the current token.
|
|
90
|
+
*/
|
|
91
|
+
export async function ensureFreshToken(originalToken) {
|
|
92
|
+
const active = activeGrantToken(originalToken);
|
|
93
|
+
const exp = expOf(active);
|
|
94
|
+
const now = Math.floor(Date.now() / 1000);
|
|
95
|
+
if (exp !== null && exp - now >= REFRESH_THRESHOLD_SECONDS)
|
|
96
|
+
return active;
|
|
97
|
+
const fresh = await refreshShared(originalToken);
|
|
98
|
+
return fresh?.token ?? active;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* The reactive path, called once after a 401 `grant_expired`: if another call
|
|
102
|
+
* already refreshed (the request went out on a stale token), hand back the
|
|
103
|
+
* newer one; otherwise attempt one shared refresh. Returns the token to retry
|
|
104
|
+
* with, or null — the caller retries AT MOST once and never loops.
|
|
105
|
+
*/
|
|
106
|
+
export async function tokenForRetry(originalToken, usedToken) {
|
|
107
|
+
const active = activeGrantToken(originalToken);
|
|
108
|
+
if (active !== usedToken)
|
|
109
|
+
return active;
|
|
110
|
+
const fresh = await refreshShared(originalToken);
|
|
111
|
+
return fresh && fresh.token !== usedToken ? fresh.token : null;
|
|
112
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@keemakr/agent-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "The floor for keemakr marketplace agents: verify the capability grant and reach tenant connections, memory, and shared platform tools through keemakr-core — without holding raw secrets or resolving the tenant yourself.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|