@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 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 | 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. |
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` | keemakr-core's base URL for capability calls, e.g. `https://app.keemakr.com`. (Derived from `KEE_CORE_JWKS_URL` if unset.) |
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 "eve/channels/auth";
36
- import { eveChannel } from "eve/channels/eve";
37
- import { grantAuth } from "@keemakr/agent-sdk";
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 "eve/tools";
50
- import { z } from "zod";
51
- import { useKee } from "@keemakr/agent-sdk";
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("email-finder", args);
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 "@keemakr/agent-sdk/connectors";
90
+ import { connectors, opNames, isReady } from '@keemakr/agent-sdk/connectors';
91
91
 
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"
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("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");
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("how should I speak to the user?", { limit: 5 });
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(); // tools this grant is entitled to
123
- await kee.tools.run("current-time"); // run one in keemakr-core
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 "@keemakr/agent-sdk";
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("invalid or expired grant");
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 *not* a credential leak. A leak is a *tenant/service* credential read in agent code.
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
- /** Write a key. Returns the stored entry. */
49
- set(namespace: string, key: string, value: unknown): Promise<MemoryEntry>;
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
- function keeError(message, status) {
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
- // Resolve core's base URL. Prefer KEE_CORE_URL; otherwise derive it from the
25
- // JWKS URL by stripping the well-known path.
26
- function coreBaseUrl() {
27
- const explicit = process.env.KEE_CORE_URL;
28
- if (explicit)
29
- return explicit.replace(/\/$/, '');
30
- const jwks = process.env.KEE_CORE_JWKS_URL;
31
- if (jwks)
32
- return jwks.replace(/\/\.well-known\/jwks\.json\/?$/, '');
33
- throw keeError('KEE_CORE_URL (or KEE_CORE_JWKS_URL) must be set to reach the Capability API');
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 res = await fetch(url, {
39
- method,
40
- headers: {
41
- ...(hasBody ? { 'content-type': 'application/json' } : {}),
42
- authorization: `Bearer ${grant.token}`,
43
- ...(grant.traceId ? { 'x-keemakr-trace-id': grant.traceId } : {}),
44
- },
45
- ...(hasBody ? { body: JSON.stringify(body ?? {}) } : {}),
46
- });
47
- const json = (await res.json().catch(() => ({})));
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
- throw keeError(json.error ?? `capability request failed (${res.status})`, res.status);
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
- const json = (await capabilityFetch(grant, `memory/${enc(namespace)}/${enc(key)}`, { value }, 'PUT'));
97
- return json.entry;
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>;
@@ -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.6.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",