@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lorekit/cli",
3
- "version": "1.20.1",
3
+ "version": "1.21.0",
4
4
  "description": "Install the LoreKit shared-memory skill and run health checks for the LoreKit MCP server.",
5
5
  "license": "MIT",
6
6
  "repository": {
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
+ }
@@ -1,25 +1,14 @@
1
- // Remote store: wraps the LoreKit MCP `memory.*` tools behind the common store
2
- // contract. Behaviour is identical to the previous direct `mcpCall` usage
3
- // this only relocates it behind the interface. Zero-dependency.
4
- import { mcpCall } from '../mcp.mjs';
5
-
6
- // LoreKit returns tool output as { content: [{ type:'text', text:'<json>' }] }.
7
- function unwrap(result) {
8
- if (!result) return null;
9
- if (Array.isArray(result.content)) {
10
- const text = result.content.map((c) => (c && c.text) || '').join('');
11
- try {
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
- async _call(name, args) {
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
- _entries(res) {
45
+ _mcpEntries(res) {
49
46
  if (!res.ok) return { ok: false, error: res.error, networkError: res.networkError };
50
- const payload = unwrap(res.result);
51
- const entries = payload && Array.isArray(payload.entries) ? payload.entries : [];
52
- return { ok: true, entries };
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
- return this._entries(await this._call('memory.list', clean({ scope, tags, limit })));
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
- return this._entries(await this._call('memory.search', clean({ q, scopes, tags })));
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 res = await this._call('memory.read', { scope, key });
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 payload = unwrap(res.result);
67
- return { ok: true, entry: payload && payload.entry ? payload.entry : payload };
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 res = await this._call('memory.write', clean(args));
72
- return { ok: res.ok, error: res.error, networkError: res.networkError, result: res.result };
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
- const res = await this._call('memory.delete', { scope, key, force: Boolean(force) });
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
- const res = await this._call('memory.archive', { scope, key });
82
- return { ok: res.ok, error: res.error, networkError: res.networkError };
115
+ // Soft-archive = DELETE without force
116
+ return this.delete({ scope, key, force: false });
83
117
  }
84
118
 
85
- // ── Org management ─────────────────────────────────────────────────────────
86
- // These proxy to the hosted MCP endpoint's org.* tools. Auth is resolved
87
- // server-side from the Bearer token; no user-id is passed by the caller.
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._call('org.create', clean({ slug, name }));
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
- const payload = unwrap(res.result);
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._call('org.list', {});
98
- return this._entries(res);
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._call('org.rename', clean({ slug, name }));
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
- const payload = unwrap(res.result);
105
- return { ok: true, ...payload };
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._call('org.delete', clean({ slug }));
151
+ const res = await this._mcp('org.delete', { slug });
110
152
  if (!res.ok) return { ok: false, error: res.error, networkError: res.networkError };
111
- const payload = unwrap(res.result);
112
- return { ok: true, ...payload };
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 MCP surface:
116
- // every read tool (memory.list / memory.search / memory.read) REQUIRES a
117
- // scope, and there is no "list all scopes" tool. Signal that honestly so the
118
- // `scopes` command shows a clear note rather than faking an inventory.
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
  }