@lorekit/cli 1.20.1 → 1.21.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 +1 -1
- package/src/mcp.mjs +66 -0
- package/src/store/remote.mjs +109 -56
- package/src/telemetry.mjs +31 -5
package/package.json
CHANGED
package/src/mcp.mjs
CHANGED
|
@@ -87,3 +87,69 @@ function parseBody(text) {
|
|
|
87
87
|
return null;
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Derive the REST API base URL from an MCP endpoint URL.
|
|
93
|
+
* e.g. 'https://ref.supabase.co/functions/v1/mcp?token=...'
|
|
94
|
+
* → 'https://ref.supabase.co/functions/v1'
|
|
95
|
+
*/
|
|
96
|
+
export function mcpToRestBase(mcpEndpointUrl) {
|
|
97
|
+
if (!mcpEndpointUrl) return null;
|
|
98
|
+
try {
|
|
99
|
+
const u = new URL(mcpEndpointUrl);
|
|
100
|
+
u.searchParams.delete('token');
|
|
101
|
+
// Strip /mcp suffix (with or without trailing slash)
|
|
102
|
+
const restPath = u.pathname.replace(/\/mcp\/?$/, '');
|
|
103
|
+
return `${u.origin}${restPath || '/'}`;
|
|
104
|
+
} catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Minimal REST fetch for LoreKit REST API endpoints.
|
|
111
|
+
* Returns { ok, httpStatus, data, error, networkError } — same shape as mcpCall.
|
|
112
|
+
*
|
|
113
|
+
* @param {string} baseUrl - REST base URL (from mcpToRestBase)
|
|
114
|
+
* @param {string} token - Bearer token
|
|
115
|
+
* @param {string} path - e.g. '/memories' or '/memories/search'
|
|
116
|
+
* @param {object} [opts]
|
|
117
|
+
* @param {string} [opts.method='GET']
|
|
118
|
+
* @param {object} [opts.body] - JSON body for POST/PATCH/DELETE
|
|
119
|
+
* @param {number} [opts.timeoutMs=10000]
|
|
120
|
+
* @param {string} [opts.traceparent] - W3C traceparent header value
|
|
121
|
+
*/
|
|
122
|
+
export async function restFetch(baseUrl, token, path, { method = 'GET', body, timeoutMs = 10000, traceparent } = {}) {
|
|
123
|
+
const controller = new AbortController();
|
|
124
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
125
|
+
try {
|
|
126
|
+
const url = `${baseUrl}${path}`;
|
|
127
|
+
const headers = {
|
|
128
|
+
accept: 'application/json',
|
|
129
|
+
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
|
|
130
|
+
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
131
|
+
...(traceparent ? { traceparent } : {}),
|
|
132
|
+
};
|
|
133
|
+
const res = await fetch(url, {
|
|
134
|
+
method,
|
|
135
|
+
headers,
|
|
136
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
137
|
+
signal: controller.signal,
|
|
138
|
+
});
|
|
139
|
+
const text = await res.text();
|
|
140
|
+
let data = null;
|
|
141
|
+
try { data = text ? JSON.parse(text) : null; } catch { /* non-JSON body */ }
|
|
142
|
+
if (!res.ok) {
|
|
143
|
+
return {
|
|
144
|
+
ok: false,
|
|
145
|
+
httpStatus: res.status,
|
|
146
|
+
error: data?.error ? { message: data.error, code: data.code } : { code: res.status, message: text.slice(0, 200) || res.statusText },
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
return { ok: true, httpStatus: res.status, data };
|
|
150
|
+
} catch (e) {
|
|
151
|
+
return { ok: false, networkError: String(e?.message ?? e) };
|
|
152
|
+
} finally {
|
|
153
|
+
clearTimeout(timer);
|
|
154
|
+
}
|
|
155
|
+
}
|
package/src/store/remote.mjs
CHANGED
|
@@ -1,25 +1,14 @@
|
|
|
1
|
-
// Remote store: wraps the LoreKit
|
|
2
|
-
// contract.
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
return JSON.parse(text);
|
|
13
|
-
} catch {
|
|
14
|
-
return null;
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
return result;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
// Drop undefined/null args so the JSON-RPC payload matches the old direct calls
|
|
21
|
-
// (e.g. `memory.list` with only { scope, limit }).
|
|
22
|
-
function clean(obj) {
|
|
1
|
+
// Remote store: wraps the LoreKit REST API `memory.*` endpoints behind the
|
|
2
|
+
// common store contract. Memory operations use the REST API for lower overhead
|
|
3
|
+
// and W3C traceparent propagation. Org operations remain on MCP because org
|
|
4
|
+
// RPCs require a Supabase JWT session (auth.uid() via SECURITY DEFINER
|
|
5
|
+
// functions), which is incompatible with the lk_* api_key tokens that CLI
|
|
6
|
+
// users have. Zero-dependency.
|
|
7
|
+
import { mcpCall, restFetch, mcpToRestBase } from '../mcp.mjs';
|
|
8
|
+
import { getActiveTraceparent } from '../telemetry.mjs';
|
|
9
|
+
|
|
10
|
+
// Drop undefined/null args so JSON payloads stay tidy.
|
|
11
|
+
function stripUndefined(obj) {
|
|
23
12
|
const out = {};
|
|
24
13
|
for (const [k, v] of Object.entries(obj || {})) if (v !== undefined && v !== null) out[k] = v;
|
|
25
14
|
return out;
|
|
@@ -31,8 +20,9 @@ export function createRemoteStore({ endpoint, token } = {}) {
|
|
|
31
20
|
|
|
32
21
|
class RemoteStore {
|
|
33
22
|
constructor(endpoint, token) {
|
|
34
|
-
this.endpoint = endpoint;
|
|
23
|
+
this.endpoint = endpoint; // MCP URL (kept for org ops)
|
|
35
24
|
this.token = token;
|
|
25
|
+
this.restBase = mcpToRestBase(endpoint); // REST base URL for memory ops
|
|
36
26
|
this.mode = 'remote';
|
|
37
27
|
}
|
|
38
28
|
|
|
@@ -40,82 +30,135 @@ class RemoteStore {
|
|
|
40
30
|
return Boolean(this.endpoint && this.token && !String(this.endpoint).includes('<project-ref>'));
|
|
41
31
|
}
|
|
42
32
|
|
|
43
|
-
|
|
33
|
+
_tp() { return getActiveTraceparent(); }
|
|
34
|
+
|
|
35
|
+
async _rest(path, opts = {}) {
|
|
36
|
+
if (!this.usable()) return { ok: false, unusable: true };
|
|
37
|
+
return restFetch(this.restBase, this.token, path, { ...opts, traceparent: this._tp() });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async _mcp(name, args) {
|
|
44
41
|
if (!this.usable()) return { ok: false, unusable: true };
|
|
45
42
|
return mcpCall(this.endpoint, this.token, 'tools/call', { name, arguments: args });
|
|
46
43
|
}
|
|
47
44
|
|
|
48
|
-
|
|
45
|
+
_mcpEntries(res) {
|
|
49
46
|
if (!res.ok) return { ok: false, error: res.error, networkError: res.networkError };
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
47
|
+
// MCP wraps results in { content: [{ type: 'text', text: '<json>' }] }
|
|
48
|
+
let payload = null;
|
|
49
|
+
if (Array.isArray(res.result?.content)) {
|
|
50
|
+
const text = res.result.content.map((c) => c?.text ?? '').join('');
|
|
51
|
+
try { payload = JSON.parse(text); } catch { /* ignore */ }
|
|
52
|
+
} else { payload = res.result; }
|
|
53
|
+
return { ok: true, entries: Array.isArray(payload?.entries) ? payload.entries : [] };
|
|
53
54
|
}
|
|
54
55
|
|
|
56
|
+
// ── Memory operations → REST ──────────────────────────────────────────────
|
|
57
|
+
|
|
55
58
|
async list({ scope, tags, limit } = {}) {
|
|
56
|
-
|
|
59
|
+
const p = new URLSearchParams();
|
|
60
|
+
if (scope) p.set('scope', scope);
|
|
61
|
+
if (tags?.length) p.set('tags', Array.isArray(tags) ? tags.join(',') : tags);
|
|
62
|
+
if (limit) p.set('limit', String(limit));
|
|
63
|
+
const res = await this._rest(`/memories?${p}`);
|
|
64
|
+
if (!res.ok) return { ok: false, error: res.error, networkError: res.networkError };
|
|
65
|
+
return { ok: true, entries: res.data?.entries ?? [] };
|
|
57
66
|
}
|
|
58
67
|
|
|
59
68
|
async search({ q, scopes, tags } = {}) {
|
|
60
|
-
|
|
69
|
+
const body = {};
|
|
70
|
+
if (q) body.q = q;
|
|
71
|
+
if (scopes?.length) body.scopes = scopes;
|
|
72
|
+
if (tags?.length) body.tags = tags;
|
|
73
|
+
const res = await this._rest('/memories/search', { method: 'POST', body });
|
|
74
|
+
if (!res.ok) return { ok: false, error: res.error, networkError: res.networkError };
|
|
75
|
+
return { ok: true, entries: res.data?.entries ?? [] };
|
|
61
76
|
}
|
|
62
77
|
|
|
63
78
|
async read({ scope, key } = {}) {
|
|
64
|
-
const
|
|
79
|
+
const p = new URLSearchParams();
|
|
80
|
+
if (scope) p.set('scope', scope);
|
|
81
|
+
if (key) p.set('key', key);
|
|
82
|
+
const res = await this._rest(`/memories?${p}`);
|
|
65
83
|
if (!res.ok) return { ok: false, error: res.error, networkError: res.networkError };
|
|
66
|
-
const
|
|
67
|
-
return { ok: true, entry:
|
|
84
|
+
const entries = res.data?.entries ?? [];
|
|
85
|
+
return { ok: true, entry: entries[0] ?? null };
|
|
68
86
|
}
|
|
69
87
|
|
|
70
88
|
async write(args = {}) {
|
|
71
|
-
const
|
|
72
|
-
|
|
89
|
+
const { scope, key, value, tags, source_agent, trigger, org, ttl_days, clear_ttl, created_at } = args;
|
|
90
|
+
const body = { scope, key, value };
|
|
91
|
+
if (tags !== undefined) body.tags = tags;
|
|
92
|
+
if (source_agent !== undefined) body.source_agent = source_agent;
|
|
93
|
+
if (trigger !== undefined) body.trigger = trigger;
|
|
94
|
+
if (org !== undefined) body.org = org;
|
|
95
|
+
if (ttl_days !== undefined) body.ttl_days = ttl_days;
|
|
96
|
+
if (clear_ttl !== undefined) body.clear_ttl = clear_ttl;
|
|
97
|
+
if (created_at !== undefined) body.created_at = created_at;
|
|
98
|
+
const res = await this._rest('/memories', { method: 'POST', body });
|
|
99
|
+
return { ok: res.ok, error: res.error, networkError: res.networkError };
|
|
73
100
|
}
|
|
74
101
|
|
|
75
|
-
async delete({ scope, key, force } = {}) {
|
|
76
|
-
|
|
102
|
+
async delete({ scope, key, force = false } = {}) {
|
|
103
|
+
if (force) {
|
|
104
|
+
// Hard-delete requires MCP — REST only supports soft-archive (archived_at)
|
|
105
|
+
const res = await this._mcp('memory.delete', { scope, key, force: true });
|
|
106
|
+
return { ok: res.ok, error: res.error, networkError: res.networkError };
|
|
107
|
+
}
|
|
108
|
+
// Soft-archive via natural-key REST endpoint
|
|
109
|
+
const p = new URLSearchParams({ scope, key });
|
|
110
|
+
const res = await this._rest(`/memories?${p}`, { method: 'DELETE' });
|
|
77
111
|
return { ok: res.ok, error: res.error, networkError: res.networkError };
|
|
78
112
|
}
|
|
79
113
|
|
|
80
114
|
async archive({ scope, key } = {}) {
|
|
81
|
-
|
|
82
|
-
return {
|
|
115
|
+
// Soft-archive = DELETE without force
|
|
116
|
+
return this.delete({ scope, key, force: false });
|
|
83
117
|
}
|
|
84
118
|
|
|
85
|
-
// ── Org
|
|
86
|
-
//
|
|
87
|
-
//
|
|
119
|
+
// ── Org operations ─────────────────────────────────────────────────────────
|
|
120
|
+
// Org RPCs (lorekit_org_*) use auth.uid() server-side via SECURITY DEFINER
|
|
121
|
+
// functions, which only works with a Supabase JWT session. CLI uses lk_*
|
|
122
|
+
// api_key tokens which provide no JWT context, so the REST /orgs endpoint
|
|
123
|
+
// returns 403 for api_key callers. Org ops stay on the MCP endpoint which
|
|
124
|
+
// handles this correctly (the Deno edge function has its own auth path).
|
|
125
|
+
// TODO: if org RPCs ever gain api_key support, switch these to REST too.
|
|
88
126
|
|
|
89
127
|
async orgCreate({ slug, name } = {}) {
|
|
90
|
-
const res = await this.
|
|
128
|
+
const res = await this._mcp('org.create', { slug, name });
|
|
91
129
|
if (!res.ok) return { ok: false, error: res.error, networkError: res.networkError };
|
|
92
|
-
|
|
130
|
+
let payload = null;
|
|
131
|
+
if (Array.isArray(res.result?.content)) {
|
|
132
|
+
try { payload = JSON.parse(res.result.content.map((c) => c?.text ?? '').join('')); } catch {}
|
|
133
|
+
} else { payload = res.result; }
|
|
93
134
|
return { ok: true, org: payload };
|
|
94
135
|
}
|
|
95
136
|
|
|
96
137
|
async orgList() {
|
|
97
|
-
const res = await this.
|
|
98
|
-
return this.
|
|
138
|
+
const res = await this._mcp('org.list', {});
|
|
139
|
+
return this._mcpEntries(res);
|
|
99
140
|
}
|
|
100
141
|
|
|
101
142
|
async orgRename({ slug, name } = {}) {
|
|
102
|
-
const res = await this.
|
|
143
|
+
const res = await this._mcp('org.rename', { slug, name });
|
|
103
144
|
if (!res.ok) return { ok: false, error: res.error, networkError: res.networkError };
|
|
104
|
-
|
|
105
|
-
|
|
145
|
+
let payload = null;
|
|
146
|
+
try { payload = Array.isArray(res.result?.content) ? JSON.parse(res.result.content.map((c) => c?.text ?? '').join('')) : res.result; } catch {}
|
|
147
|
+
return { ok: true, ...(payload ?? {}) };
|
|
106
148
|
}
|
|
107
149
|
|
|
108
150
|
async orgDelete({ slug } = {}) {
|
|
109
|
-
const res = await this.
|
|
151
|
+
const res = await this._mcp('org.delete', { slug });
|
|
110
152
|
if (!res.ok) return { ok: false, error: res.error, networkError: res.networkError };
|
|
111
|
-
|
|
112
|
-
|
|
153
|
+
let payload = null;
|
|
154
|
+
try { payload = Array.isArray(res.result?.content) ? JSON.parse(res.result.content.map((c) => c?.text ?? '').join('')) : res.result; } catch {}
|
|
155
|
+
return { ok: true, ...(payload ?? {}) };
|
|
113
156
|
}
|
|
114
157
|
|
|
115
|
-
// Store-wide scope enumeration is NOT possible against the hosted
|
|
116
|
-
// every read tool
|
|
117
|
-
//
|
|
118
|
-
//
|
|
158
|
+
// Store-wide scope enumeration is NOT possible against the hosted REST surface:
|
|
159
|
+
// every read tool requires a scope, and there is no "list all scopes" endpoint.
|
|
160
|
+
// Signal that honestly so the `scopes` command shows a clear note rather than
|
|
161
|
+
// faking an inventory.
|
|
119
162
|
async listScopes() {
|
|
120
163
|
return { ok: false, unsupported: true };
|
|
121
164
|
}
|
|
@@ -123,6 +166,16 @@ class RemoteStore {
|
|
|
123
166
|
// Connectivity probe for doctor — a transport check, not a memory op.
|
|
124
167
|
async ping() {
|
|
125
168
|
if (!this.usable()) return { ok: false, unusable: true };
|
|
169
|
+
// Use the /health function as a connectivity probe (public, no auth)
|
|
170
|
+
const healthUrl = this.restBase
|
|
171
|
+
? `${this.restBase.replace(/\/functions\/v1$/, '')}/functions/v1/health`
|
|
172
|
+
: null;
|
|
173
|
+
if (healthUrl) {
|
|
174
|
+
try {
|
|
175
|
+
const res = await fetch(healthUrl, { signal: AbortSignal.timeout(5000) });
|
|
176
|
+
return { ok: res.ok, httpStatus: res.status };
|
|
177
|
+
} catch (e) { return { ok: false, networkError: String(e?.message ?? e) }; }
|
|
178
|
+
}
|
|
126
179
|
return mcpCall(this.endpoint, this.token, 'tools/list', {});
|
|
127
180
|
}
|
|
128
181
|
}
|
package/src/telemetry.mjs
CHANGED
|
@@ -40,6 +40,22 @@ const FLAG_ATTRS = ['global', 'project', 'deep', 'yes', 'force', 'no-hooks', 'js
|
|
|
40
40
|
|
|
41
41
|
const OFF_VALUES = new Set(['0', 'off', 'false', 'no', 'disable', 'disabled']);
|
|
42
42
|
|
|
43
|
+
// ── Active trace context (for traceparent propagation to REST calls) ──────────
|
|
44
|
+
// Set at the start of each traced command run; used by RemoteStore to
|
|
45
|
+
// forward the CLI span's trace identity to outgoing REST requests.
|
|
46
|
+
let _activeTraceId = null;
|
|
47
|
+
let _activeSpanId = null;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Get the W3C traceparent for the currently-running CLI command, or null.
|
|
51
|
+
* Called by RemoteStore to inject the header into REST fetches so CLI spans
|
|
52
|
+
* are linked to REST API spans in Dash0.
|
|
53
|
+
*/
|
|
54
|
+
export function getActiveTraceparent() {
|
|
55
|
+
if (!_activeTraceId || !_activeSpanId) return null;
|
|
56
|
+
return `00-${_activeTraceId}-${_activeSpanId}-01`;
|
|
57
|
+
}
|
|
58
|
+
|
|
43
59
|
// ── Config resolution ─────────────────────────────────────────────────────────
|
|
44
60
|
|
|
45
61
|
/**
|
|
@@ -155,7 +171,7 @@ export function commandAttributes({ command, args = {}, outcome, exitCode, extra
|
|
|
155
171
|
return attrs;
|
|
156
172
|
}
|
|
157
173
|
|
|
158
|
-
export function buildTracePayload({ version, name, attributes, startMs, endMs, status, statusMessage }) {
|
|
174
|
+
export function buildTracePayload({ version, name, attributes, startMs, endMs, status, statusMessage, traceId, spanId }) {
|
|
159
175
|
return {
|
|
160
176
|
resourceSpans: [
|
|
161
177
|
{
|
|
@@ -165,8 +181,8 @@ export function buildTracePayload({ version, name, attributes, startMs, endMs, s
|
|
|
165
181
|
scope: { name: 'lorekit-cli', version: String(version) },
|
|
166
182
|
spans: [
|
|
167
183
|
{
|
|
168
|
-
traceId: randHex(16),
|
|
169
|
-
spanId: randHex(8),
|
|
184
|
+
traceId: traceId ?? randHex(16),
|
|
185
|
+
spanId: spanId ?? randHex(8),
|
|
170
186
|
name,
|
|
171
187
|
kind: 1, // INTERNAL
|
|
172
188
|
startTimeUnixNano: String(startMs * 1_000_000),
|
|
@@ -243,9 +259,9 @@ async function post(url, headers, payload, timeoutMs) {
|
|
|
243
259
|
* can never delay or fail the CLI. Awaited before process exit (Node would
|
|
244
260
|
* otherwise drop the in-flight request), but capped at timeoutMs.
|
|
245
261
|
*/
|
|
246
|
-
export async function exportInvocation(config, { version, name, attributes, startMs, endMs, status, statusMessage }, { timeoutMs = 1500 } = {}) {
|
|
262
|
+
export async function exportInvocation(config, { version, name, attributes, startMs, endMs, status, statusMessage, traceId, spanId }, { timeoutMs = 1500 } = {}) {
|
|
247
263
|
if (!config || !config.enabled) return;
|
|
248
|
-
const trace = buildTracePayload({ version, name, attributes, startMs, endMs, status, statusMessage });
|
|
264
|
+
const trace = buildTracePayload({ version, name, attributes, startMs, endMs, status, statusMessage, traceId, spanId });
|
|
249
265
|
const metric = buildMetricsPayload({ version, attributes, startMs, endMs });
|
|
250
266
|
await Promise.all([
|
|
251
267
|
post(`${config.endpoint}/v1/traces`, config.headers, trace, timeoutMs),
|
|
@@ -305,6 +321,11 @@ export async function traceCommand(command, args, version, run) {
|
|
|
305
321
|
// (exit 1) for every user without an OTLP endpoint configured.
|
|
306
322
|
if (!config.enabled) return normalizeExitCode(await run());
|
|
307
323
|
|
|
324
|
+
// Generate trace context before run() so REST calls can forward it as traceparent.
|
|
325
|
+
// The same IDs are reused in exportInvocation() to link the CLI span to REST spans.
|
|
326
|
+
_activeTraceId = randHex(16);
|
|
327
|
+
_activeSpanId = randHex(8);
|
|
328
|
+
|
|
308
329
|
const startMs = Date.now();
|
|
309
330
|
let exitCode = 0;
|
|
310
331
|
let status = 'ok';
|
|
@@ -360,9 +381,14 @@ export async function traceCommand(command, args, version, run) {
|
|
|
360
381
|
endMs: Date.now(),
|
|
361
382
|
status,
|
|
362
383
|
statusMessage,
|
|
384
|
+
traceId: _activeTraceId,
|
|
385
|
+
spanId: _activeSpanId,
|
|
363
386
|
});
|
|
364
387
|
} catch {
|
|
365
388
|
// never let telemetry break the CLI
|
|
389
|
+
} finally {
|
|
390
|
+
_activeTraceId = null;
|
|
391
|
+
_activeSpanId = null;
|
|
366
392
|
}
|
|
367
393
|
}
|
|
368
394
|
}
|