@lorekit/cli 1.20.1 → 1.22.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 +7 -7
- package/bin/lorekit.mjs +4 -3
- package/package.json +1 -1
- package/src/mcp.mjs +72 -1
- package/src/scopes.mjs +22 -19
- package/src/store/remote.mjs +131 -61
- package/src/telemetry.mjs +58 -6
package/README.md
CHANGED
|
@@ -224,13 +224,13 @@ stored by basename only) — so every scope is reconstructed verbatim. Lessons
|
|
|
224
224
|
present in both tiers are counted once (project shadows home, the same merge
|
|
225
225
|
`list` uses); archived lessons are excluded.
|
|
226
226
|
|
|
227
|
-
**Remote enumeration is
|
|
228
|
-
|
|
229
|
-
(
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
in `list`.
|
|
227
|
+
**Remote enumeration is exact too.** `RemoteStore.listScopes()` calls
|
|
228
|
+
`GET /memories/scopes`, which aggregates one `{ scope, count }` row per scope in
|
|
229
|
+
Postgres (never a truncatable `select('scope')` plus a client-side dedupe), so
|
|
230
|
+
the Remote section is a real inventory rendered through the same helpers as the
|
|
231
|
+
Offline one. A denied, unconfigured, unreachable, or erroring remote degrades to
|
|
232
|
+
a short, accurate note (network error / HTTP status — never a faked listing) at
|
|
233
|
+
exit 0. `--endpoint` / `--token` / `--store` behave as in `list`.
|
|
234
234
|
|
|
235
235
|
### `lorekit diff`
|
|
236
236
|
|
package/bin/lorekit.mjs
CHANGED
|
@@ -348,9 +348,10 @@ is a full inventory — it surfaces scopes anywhere in the store, regardless of
|
|
|
348
348
|
current directory.
|
|
349
349
|
|
|
350
350
|
Offline counts are exact: each scope is read from the memory files' frontmatter,
|
|
351
|
-
not reverse-mapped from the directory layout.
|
|
352
|
-
|
|
353
|
-
|
|
351
|
+
not reverse-mapped from the directory layout. Remote counts are exact too — they
|
|
352
|
+
come from \`GET /memories/scopes\`, which aggregates one row per scope server-side.
|
|
353
|
+
A denied, unconfigured, or unreachable remote degrades to a short, accurate note
|
|
354
|
+
rather than an error (exit 0).
|
|
354
355
|
|
|
355
356
|
${c.bold('Options')}
|
|
356
357
|
-d, --dir <path> Target project root (default: current directory)
|
package/package.json
CHANGED
package/src/mcp.mjs
CHANGED
|
@@ -27,7 +27,11 @@ export function buildRemoteUrl(endpoint, token) {
|
|
|
27
27
|
let idCounter = 0;
|
|
28
28
|
|
|
29
29
|
// Returns { ok, httpStatus, result, error, networkError }.
|
|
30
|
-
|
|
30
|
+
//
|
|
31
|
+
// `opts.traceparent` is an optional W3C traceparent header value (see
|
|
32
|
+
// src/telemetry.mjs `getActiveTraceparent`); when present it is forwarded so
|
|
33
|
+
// the server-side span joins the CLI's trace — same idiom as restFetch.
|
|
34
|
+
export async function mcpCall(endpoint, token, method, params = {}, { timeoutMs = 10000, traceparent } = {}) {
|
|
31
35
|
const controller = new AbortController();
|
|
32
36
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
33
37
|
try {
|
|
@@ -40,6 +44,7 @@ export async function mcpCall(endpoint, token, method, params = {}, { timeoutMs
|
|
|
40
44
|
'content-type': 'application/json',
|
|
41
45
|
accept: 'application/json, text/event-stream',
|
|
42
46
|
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
47
|
+
...(traceparent ? { traceparent } : {}),
|
|
43
48
|
},
|
|
44
49
|
body: JSON.stringify({ jsonrpc: '2.0', id: ++idCounter, method, params }),
|
|
45
50
|
signal: controller.signal,
|
|
@@ -87,3 +92,69 @@ function parseBody(text) {
|
|
|
87
92
|
return null;
|
|
88
93
|
}
|
|
89
94
|
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Derive the REST API base URL from an MCP endpoint URL.
|
|
98
|
+
* e.g. 'https://ref.supabase.co/functions/v1/mcp?token=...'
|
|
99
|
+
* → 'https://ref.supabase.co/functions/v1'
|
|
100
|
+
*/
|
|
101
|
+
export function mcpToRestBase(mcpEndpointUrl) {
|
|
102
|
+
if (!mcpEndpointUrl) return null;
|
|
103
|
+
try {
|
|
104
|
+
const u = new URL(mcpEndpointUrl);
|
|
105
|
+
u.searchParams.delete('token');
|
|
106
|
+
// Strip /mcp suffix (with or without trailing slash)
|
|
107
|
+
const restPath = u.pathname.replace(/\/mcp\/?$/, '');
|
|
108
|
+
return `${u.origin}${restPath || '/'}`;
|
|
109
|
+
} catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Minimal REST fetch for LoreKit REST API endpoints.
|
|
116
|
+
* Returns { ok, httpStatus, data, error, networkError } — same shape as mcpCall.
|
|
117
|
+
*
|
|
118
|
+
* @param {string} baseUrl - REST base URL (from mcpToRestBase)
|
|
119
|
+
* @param {string} token - Bearer token
|
|
120
|
+
* @param {string} path - e.g. '/memories' or '/memories/search'
|
|
121
|
+
* @param {object} [opts]
|
|
122
|
+
* @param {string} [opts.method='GET']
|
|
123
|
+
* @param {object} [opts.body] - JSON body for POST/PATCH/DELETE
|
|
124
|
+
* @param {number} [opts.timeoutMs=10000]
|
|
125
|
+
* @param {string} [opts.traceparent] - W3C traceparent header value
|
|
126
|
+
*/
|
|
127
|
+
export async function restFetch(baseUrl, token, path, { method = 'GET', body, timeoutMs = 10000, traceparent } = {}) {
|
|
128
|
+
const controller = new AbortController();
|
|
129
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
130
|
+
try {
|
|
131
|
+
const url = `${baseUrl}${path}`;
|
|
132
|
+
const headers = {
|
|
133
|
+
accept: 'application/json',
|
|
134
|
+
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
|
|
135
|
+
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
136
|
+
...(traceparent ? { traceparent } : {}),
|
|
137
|
+
};
|
|
138
|
+
const res = await fetch(url, {
|
|
139
|
+
method,
|
|
140
|
+
headers,
|
|
141
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
142
|
+
signal: controller.signal,
|
|
143
|
+
});
|
|
144
|
+
const text = await res.text();
|
|
145
|
+
let data = null;
|
|
146
|
+
try { data = text ? JSON.parse(text) : null; } catch { /* non-JSON body */ }
|
|
147
|
+
if (!res.ok) {
|
|
148
|
+
return {
|
|
149
|
+
ok: false,
|
|
150
|
+
httpStatus: res.status,
|
|
151
|
+
error: data?.error ? { message: data.error, code: data.code } : { code: res.status, message: text.slice(0, 200) || res.statusText },
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
return { ok: true, httpStatus: res.status, data };
|
|
155
|
+
} catch (e) {
|
|
156
|
+
return { ok: false, networkError: String(e?.message ?? e) };
|
|
157
|
+
} finally {
|
|
158
|
+
clearTimeout(timer);
|
|
159
|
+
}
|
|
160
|
+
}
|
package/src/scopes.mjs
CHANGED
|
@@ -13,10 +13,12 @@
|
|
|
13
13
|
// mapping the on-disk directory layout, which is lossy for `project::{name}`
|
|
14
14
|
// (stored by basename only) — so every scope is reconstructed exactly.
|
|
15
15
|
//
|
|
16
|
-
// Remote enumeration is
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
16
|
+
// Remote enumeration is EXACT too: `RemoteStore.listScopes()` calls
|
|
17
|
+
// `GET /memories/scopes`, which aggregates one row per scope in Postgres (never
|
|
18
|
+
// a truncatable `select('scope')` + client-side dedupe), so both sections are
|
|
19
|
+
// real inventories rendered through the same pure helpers. A denied,
|
|
20
|
+
// unconfigured, unreachable, or erroring remote still degrades to a short,
|
|
21
|
+
// accurate note at exit 0 — never a throw.
|
|
20
22
|
//
|
|
21
23
|
// Graceful, read-only, human-facing (the bin wraps it in `traceCommand`).
|
|
22
24
|
// `LOREKIT_DENY` suppresses a section; `--scope <s>` filters the inventory to
|
|
@@ -26,15 +28,9 @@ import process from 'node:process';
|
|
|
26
28
|
import { resolveProjectRoot } from './config.mjs';
|
|
27
29
|
import { resolveDenies } from './control.mjs';
|
|
28
30
|
import { resolveStores, remoteUnavailableReason } from './stores.mjs';
|
|
29
|
-
import { summarizeScopeInventory, filterScopeInventory } from './lessons-view.mjs';
|
|
31
|
+
import { summarizeScopeInventory, filterScopeInventory, describeError } from './lessons-view.mjs';
|
|
30
32
|
import { log, heading, status, c } from './util.mjs';
|
|
31
33
|
|
|
32
|
-
// The honest note the Remote section shows when it isn't denied/unconfigured:
|
|
33
|
-
// the hosted MCP surface simply can't enumerate scopes. Exported so tests can
|
|
34
|
-
// assert the exact wording rather than a fragile substring.
|
|
35
|
-
export const REMOTE_SCOPES_UNSUPPORTED =
|
|
36
|
-
'remote scope enumeration is not supported by the hosted MCP surface (memory.list requires a scope)';
|
|
37
|
-
|
|
38
34
|
export async function scopes(args) {
|
|
39
35
|
const root = resolveProjectRoot(args.dir);
|
|
40
36
|
const env = { ...process.env };
|
|
@@ -63,16 +59,21 @@ export async function scopes(args) {
|
|
|
63
59
|
offlineSection = { available: true, ...summarizeScopeInventory(inventory) };
|
|
64
60
|
}
|
|
65
61
|
|
|
66
|
-
// Remote:
|
|
67
|
-
//
|
|
68
|
-
//
|
|
62
|
+
// Remote: the hosted enumeration, via `GET /memories/scopes`. Precedence is
|
|
63
|
+
// unchanged from the other read commands — deny first, then connectivity, then
|
|
64
|
+
// the call itself. A failed call degrades to the same bounded, non-PII
|
|
65
|
+
// `describeError` note a failed `list()` gets (network error / HTTP status),
|
|
66
|
+
// never a throw and never a faked inventory.
|
|
69
67
|
let remoteSection;
|
|
70
68
|
if (remoteDenied) {
|
|
71
69
|
remoteSection = { available: false, reason: `disabled by deny constraint (${remoteDenied.source})` };
|
|
72
70
|
} else if (!remote.usable()) {
|
|
73
71
|
remoteSection = { available: false, reason: remoteUnavailableReason(connection) };
|
|
74
72
|
} else {
|
|
75
|
-
|
|
73
|
+
const res = await remote.listScopes();
|
|
74
|
+
remoteSection = res.ok
|
|
75
|
+
? { available: true, ...summarizeScopeInventory(filterScopeInventory(res.scopes, filter)) }
|
|
76
|
+
: { available: false, reason: describeError(res) };
|
|
76
77
|
}
|
|
77
78
|
|
|
78
79
|
if (args.json) {
|
|
@@ -89,15 +90,17 @@ export async function scopes(args) {
|
|
|
89
90
|
log('');
|
|
90
91
|
}
|
|
91
92
|
|
|
92
|
-
// Bounded, non-PII telemetry extras (counts +
|
|
93
|
-
// string, path, or token. `remote_available`
|
|
94
|
-
//
|
|
93
|
+
// Bounded, non-PII telemetry extras (counts + booleans) — never a scope
|
|
94
|
+
// string, path, or token. `remote_available` now reflects whether the hosted
|
|
95
|
+
// enumeration actually answered; the remote counts mirror the offline pair.
|
|
95
96
|
return {
|
|
96
97
|
exitCode: 0,
|
|
97
98
|
'lorekit.cli.scopes.offline_scope_count': offlineSection.available ? offlineSection.scopes.length : 0,
|
|
98
99
|
'lorekit.cli.scopes.offline_total': offlineSection.available ? offlineSection.total : 0,
|
|
99
100
|
'lorekit.cli.scopes.filtered': Boolean(filter),
|
|
100
|
-
'lorekit.cli.scopes.
|
|
101
|
+
'lorekit.cli.scopes.remote_scope_count': remoteSection.available ? remoteSection.scopes.length : 0,
|
|
102
|
+
'lorekit.cli.scopes.remote_total': remoteSection.available ? remoteSection.total : 0,
|
|
103
|
+
'lorekit.cli.scopes.remote_available': remoteSection.available,
|
|
101
104
|
};
|
|
102
105
|
}
|
|
103
106
|
|
package/src/store/remote.mjs
CHANGED
|
@@ -1,25 +1,17 @@
|
|
|
1
|
-
// Remote store: wraps the LoreKit
|
|
2
|
-
// contract.
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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. EVERY memory operation goes over REST — list, search,
|
|
3
|
+
// read, write, delete (soft-archive AND `?force=true` hard-delete) and the
|
|
4
|
+
// store-wide `listScopes()` enumeration — for lower overhead and W3C
|
|
5
|
+
// traceparent propagation. MCP remains ONLY for the four `org.*` calls and the
|
|
6
|
+
// `ping` fallback: org RPCs require a Supabase JWT session (they resolve the
|
|
7
|
+
// actor via auth.uid() inside SECURITY DEFINER functions), which is
|
|
8
|
+
// incompatible with the lk_* api_key tokens that CLI users have.
|
|
9
|
+
// Zero-dependency.
|
|
10
|
+
import { mcpCall, restFetch, mcpToRestBase } from '../mcp.mjs';
|
|
11
|
+
import { getActiveTraceparent } from '../telemetry.mjs';
|
|
12
|
+
|
|
13
|
+
// Drop undefined/null args so JSON payloads stay tidy.
|
|
14
|
+
function stripUndefined(obj) {
|
|
23
15
|
const out = {};
|
|
24
16
|
for (const [k, v] of Object.entries(obj || {})) if (v !== undefined && v !== null) out[k] = v;
|
|
25
17
|
return out;
|
|
@@ -31,8 +23,9 @@ export function createRemoteStore({ endpoint, token } = {}) {
|
|
|
31
23
|
|
|
32
24
|
class RemoteStore {
|
|
33
25
|
constructor(endpoint, token) {
|
|
34
|
-
this.endpoint = endpoint;
|
|
26
|
+
this.endpoint = endpoint; // MCP URL (kept for org ops)
|
|
35
27
|
this.token = token;
|
|
28
|
+
this.restBase = mcpToRestBase(endpoint); // REST base URL for memory ops
|
|
36
29
|
this.mode = 'remote';
|
|
37
30
|
}
|
|
38
31
|
|
|
@@ -40,89 +33,166 @@ class RemoteStore {
|
|
|
40
33
|
return Boolean(this.endpoint && this.token && !String(this.endpoint).includes('<project-ref>'));
|
|
41
34
|
}
|
|
42
35
|
|
|
43
|
-
|
|
36
|
+
_tp() { return getActiveTraceparent(); }
|
|
37
|
+
|
|
38
|
+
async _rest(path, opts = {}) {
|
|
39
|
+
if (!this.usable()) return { ok: false, unusable: true };
|
|
40
|
+
return restFetch(this.restBase, this.token, path, { ...opts, traceparent: this._tp() });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async _mcp(name, args) {
|
|
44
44
|
if (!this.usable()) return { ok: false, unusable: true };
|
|
45
|
-
return mcpCall(this.endpoint, this.token, 'tools/call', { name, arguments: args });
|
|
45
|
+
return mcpCall(this.endpoint, this.token, 'tools/call', { name, arguments: args }, { traceparent: this._tp() });
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
_mcpEntries(res) {
|
|
49
49
|
if (!res.ok) return { ok: false, error: res.error, networkError: res.networkError };
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
// MCP wraps results in { content: [{ type: 'text', text: '<json>' }] }
|
|
51
|
+
let payload = null;
|
|
52
|
+
if (Array.isArray(res.result?.content)) {
|
|
53
|
+
const text = res.result.content.map((c) => c?.text ?? '').join('');
|
|
54
|
+
try { payload = JSON.parse(text); } catch { /* ignore */ }
|
|
55
|
+
} else { payload = res.result; }
|
|
56
|
+
return { ok: true, entries: Array.isArray(payload?.entries) ? payload.entries : [] };
|
|
53
57
|
}
|
|
54
58
|
|
|
59
|
+
// ── Memory operations → REST ──────────────────────────────────────────────
|
|
60
|
+
|
|
55
61
|
async list({ scope, tags, limit } = {}) {
|
|
56
|
-
|
|
62
|
+
const p = new URLSearchParams();
|
|
63
|
+
if (scope) p.set('scope', scope);
|
|
64
|
+
if (tags?.length) p.set('tags', Array.isArray(tags) ? tags.join(',') : tags);
|
|
65
|
+
if (limit) p.set('limit', String(limit));
|
|
66
|
+
const res = await this._rest(`/memories?${p}`);
|
|
67
|
+
if (!res.ok) return { ok: false, error: res.error, networkError: res.networkError };
|
|
68
|
+
return { ok: true, entries: res.data?.entries ?? [] };
|
|
57
69
|
}
|
|
58
70
|
|
|
59
71
|
async search({ q, scopes, tags } = {}) {
|
|
60
|
-
|
|
72
|
+
const body = {};
|
|
73
|
+
if (q) body.q = q;
|
|
74
|
+
if (scopes?.length) body.scopes = scopes;
|
|
75
|
+
if (tags?.length) body.tags = tags;
|
|
76
|
+
const res = await this._rest('/memories/search', { method: 'POST', body });
|
|
77
|
+
if (!res.ok) return { ok: false, error: res.error, networkError: res.networkError };
|
|
78
|
+
return { ok: true, entries: res.data?.entries ?? [] };
|
|
61
79
|
}
|
|
62
80
|
|
|
63
81
|
async read({ scope, key } = {}) {
|
|
64
|
-
const
|
|
82
|
+
const p = new URLSearchParams();
|
|
83
|
+
if (scope) p.set('scope', scope);
|
|
84
|
+
if (key) p.set('key', key);
|
|
85
|
+
// scope+key is unique, so one row is all there can be — don't pull the default page of 50.
|
|
86
|
+
p.set('limit', '1');
|
|
87
|
+
const res = await this._rest(`/memories?${p}`);
|
|
65
88
|
if (!res.ok) return { ok: false, error: res.error, networkError: res.networkError };
|
|
66
|
-
const
|
|
67
|
-
return { ok: true, entry:
|
|
89
|
+
const entries = res.data?.entries ?? [];
|
|
90
|
+
return { ok: true, entry: entries[0] ?? null };
|
|
68
91
|
}
|
|
69
92
|
|
|
70
93
|
async write(args = {}) {
|
|
71
|
-
const
|
|
72
|
-
|
|
94
|
+
const { scope, key, value, tags, source_agent, trigger, org, ttl_days, clear_ttl, created_at } = args;
|
|
95
|
+
const body = { scope, key, value };
|
|
96
|
+
if (tags !== undefined) body.tags = tags;
|
|
97
|
+
if (source_agent !== undefined) body.source_agent = source_agent;
|
|
98
|
+
if (trigger !== undefined) body.trigger = trigger;
|
|
99
|
+
if (org !== undefined) body.org = org;
|
|
100
|
+
if (ttl_days !== undefined) body.ttl_days = ttl_days;
|
|
101
|
+
if (clear_ttl !== undefined) body.clear_ttl = clear_ttl;
|
|
102
|
+
if (created_at !== undefined) body.created_at = created_at;
|
|
103
|
+
const res = await this._rest('/memories', { method: 'POST', body });
|
|
104
|
+
return { ok: res.ok, error: res.error, networkError: res.networkError };
|
|
73
105
|
}
|
|
74
106
|
|
|
75
|
-
|
|
76
|
-
|
|
107
|
+
// Natural-key DELETE. Without `force` the server soft-archives (stamps
|
|
108
|
+
// `archived_at`); `?force=true` hard-deletes the row outright — both forms of
|
|
109
|
+
// the same REST route (supabase/functions/memories/handlers/remove.ts).
|
|
110
|
+
async delete({ scope, key, force = false } = {}) {
|
|
111
|
+
const p = new URLSearchParams({ scope, key });
|
|
112
|
+
if (force) p.set('force', 'true');
|
|
113
|
+
const res = await this._rest(`/memories?${p}`, { method: 'DELETE' });
|
|
77
114
|
return { ok: res.ok, error: res.error, networkError: res.networkError };
|
|
78
115
|
}
|
|
79
116
|
|
|
80
117
|
async archive({ scope, key } = {}) {
|
|
81
|
-
|
|
82
|
-
return {
|
|
118
|
+
// Soft-archive = DELETE without force
|
|
119
|
+
return this.delete({ scope, key, force: false });
|
|
83
120
|
}
|
|
84
121
|
|
|
85
|
-
// ── Org
|
|
86
|
-
//
|
|
87
|
-
//
|
|
122
|
+
// ── Org operations ─────────────────────────────────────────────────────────
|
|
123
|
+
// Org RPCs (lorekit_org_*) use auth.uid() server-side via SECURITY DEFINER
|
|
124
|
+
// functions, which only works with a Supabase JWT session. CLI uses lk_*
|
|
125
|
+
// api_key tokens which provide no JWT context, so the REST /orgs endpoint
|
|
126
|
+
// returns 403 for api_key callers. Org ops stay on the MCP endpoint which
|
|
127
|
+
// handles this correctly (the Deno edge function has its own auth path).
|
|
128
|
+
// TODO: if org RPCs ever gain api_key support, switch these to REST too.
|
|
88
129
|
|
|
89
130
|
async orgCreate({ slug, name } = {}) {
|
|
90
|
-
const res = await this.
|
|
131
|
+
const res = await this._mcp('org.create', { slug, name });
|
|
91
132
|
if (!res.ok) return { ok: false, error: res.error, networkError: res.networkError };
|
|
92
|
-
|
|
133
|
+
let payload = null;
|
|
134
|
+
if (Array.isArray(res.result?.content)) {
|
|
135
|
+
try { payload = JSON.parse(res.result.content.map((c) => c?.text ?? '').join('')); } catch {}
|
|
136
|
+
} else { payload = res.result; }
|
|
93
137
|
return { ok: true, org: payload };
|
|
94
138
|
}
|
|
95
139
|
|
|
96
140
|
async orgList() {
|
|
97
|
-
const res = await this.
|
|
98
|
-
return this.
|
|
141
|
+
const res = await this._mcp('org.list', {});
|
|
142
|
+
return this._mcpEntries(res);
|
|
99
143
|
}
|
|
100
144
|
|
|
101
145
|
async orgRename({ slug, name } = {}) {
|
|
102
|
-
const res = await this.
|
|
146
|
+
const res = await this._mcp('org.rename', { slug, name });
|
|
103
147
|
if (!res.ok) return { ok: false, error: res.error, networkError: res.networkError };
|
|
104
|
-
|
|
105
|
-
|
|
148
|
+
let payload = null;
|
|
149
|
+
try { payload = Array.isArray(res.result?.content) ? JSON.parse(res.result.content.map((c) => c?.text ?? '').join('')) : res.result; } catch {}
|
|
150
|
+
return { ok: true, ...(payload ?? {}) };
|
|
106
151
|
}
|
|
107
152
|
|
|
108
153
|
async orgDelete({ slug } = {}) {
|
|
109
|
-
const res = await this.
|
|
154
|
+
const res = await this._mcp('org.delete', { slug });
|
|
110
155
|
if (!res.ok) return { ok: false, error: res.error, networkError: res.networkError };
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
156
|
+
let payload = null;
|
|
157
|
+
try { payload = Array.isArray(res.result?.content) ? JSON.parse(res.result.content.map((c) => c?.text ?? '').join('')) : res.result; } catch {}
|
|
158
|
+
return { ok: true, ...(payload ?? {}) };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Store-wide scope enumeration — every distinct scope the caller can see with
|
|
162
|
+
// its count of active (non-archived, non-expired) memories. `GET
|
|
163
|
+
// /memories/scopes` aggregates in Postgres, so the answer is exact at any size
|
|
164
|
+
// (see supabase/functions/memories/handlers/scopes.ts).
|
|
165
|
+
//
|
|
166
|
+
// The `scopes` array is the SAME `[{ scope, count }]` inventory shape
|
|
167
|
+
// `LocalStore.listScopes()` returns, so `scopes.mjs` feeds both through the
|
|
168
|
+
// same pure `filterScopeInventory`/`summarizeScopeInventory` helpers. Ordering
|
|
169
|
+
// is not relied upon (the server sorts by scope asc; the view re-sorts by
|
|
170
|
+
// scope type). Failures use this store's standard `{ ok:false, error,
|
|
171
|
+
// networkError }` envelope so the caller can degrade gracefully.
|
|
119
172
|
async listScopes() {
|
|
120
|
-
|
|
173
|
+
const res = await this._rest('/memories/scopes');
|
|
174
|
+
if (!res.ok) return { ok: false, error: res.error, networkError: res.networkError, unusable: res.unusable };
|
|
175
|
+
const scopes = Array.isArray(res.data?.scopes) ? res.data.scopes : [];
|
|
176
|
+
return { ok: true, scopes: scopes.map((s) => ({ scope: s.scope, count: Number(s.count) || 0 })) };
|
|
121
177
|
}
|
|
122
178
|
|
|
123
179
|
// Connectivity probe for doctor — a transport check, not a memory op.
|
|
124
180
|
async ping() {
|
|
125
181
|
if (!this.usable()) return { ok: false, unusable: true };
|
|
126
|
-
|
|
182
|
+
// Use the /health function as a connectivity probe (public, no auth)
|
|
183
|
+
const healthUrl = this.restBase
|
|
184
|
+
? `${this.restBase.replace(/\/functions\/v1$/, '')}/functions/v1/health`
|
|
185
|
+
: null;
|
|
186
|
+
if (healthUrl) {
|
|
187
|
+
const tp = this._tp();
|
|
188
|
+
try {
|
|
189
|
+
const res = await fetch(healthUrl, {
|
|
190
|
+
signal: AbortSignal.timeout(5000),
|
|
191
|
+
...(tp ? { headers: { traceparent: tp } } : {}),
|
|
192
|
+
});
|
|
193
|
+
return { ok: res.ok, httpStatus: res.status };
|
|
194
|
+
} catch (e) { return { ok: false, networkError: String(e?.message ?? e) }; }
|
|
195
|
+
}
|
|
196
|
+
return mcpCall(this.endpoint, this.token, 'tools/list', {}, { traceparent: this._tp() });
|
|
127
197
|
}
|
|
128
198
|
}
|
package/src/telemetry.mjs
CHANGED
|
@@ -40,6 +40,33 @@ 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 EVERY traced command run — including runs where export is
|
|
45
|
+
// disabled. Context propagation is deliberately decoupled from export: a user
|
|
46
|
+
// who opted out (or who simply has no OTLP endpoint configured) must still get
|
|
47
|
+
// correlated server-side traces, they just don't contribute a CLI span.
|
|
48
|
+
let _activeTraceId = null;
|
|
49
|
+
let _activeSpanId = null;
|
|
50
|
+
// Whether the current command's span will actually be exported. Drives the
|
|
51
|
+
// W3C `sampled` bit only — never whether the header is sent.
|
|
52
|
+
let _activeSampled = false;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Get the W3C traceparent for the currently-running CLI command, or null when
|
|
56
|
+
* no command is running. Called by RemoteStore to inject the header into
|
|
57
|
+
* outgoing REST/MCP calls so the server span joins the CLI's trace.
|
|
58
|
+
*
|
|
59
|
+
* Flags are `01` when the CLI span is exported and `00` when it is not — the
|
|
60
|
+
* trace id is still carried either way, so the server can correlate. This
|
|
61
|
+
* mirrors `formatTraceparent` in `packages/mcp-core/src/trace-context.ts`
|
|
62
|
+
* (the CLI is zero-dep `.mjs` and cannot import the TS module); keep the two
|
|
63
|
+
* byte-consistent.
|
|
64
|
+
*/
|
|
65
|
+
export function getActiveTraceparent() {
|
|
66
|
+
if (!_activeTraceId || !_activeSpanId) return null;
|
|
67
|
+
return `00-${_activeTraceId}-${_activeSpanId}-${_activeSampled ? '01' : '00'}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
43
70
|
// ── Config resolution ─────────────────────────────────────────────────────────
|
|
44
71
|
|
|
45
72
|
/**
|
|
@@ -155,7 +182,7 @@ export function commandAttributes({ command, args = {}, outcome, exitCode, extra
|
|
|
155
182
|
return attrs;
|
|
156
183
|
}
|
|
157
184
|
|
|
158
|
-
export function buildTracePayload({ version, name, attributes, startMs, endMs, status, statusMessage }) {
|
|
185
|
+
export function buildTracePayload({ version, name, attributes, startMs, endMs, status, statusMessage, traceId, spanId }) {
|
|
159
186
|
return {
|
|
160
187
|
resourceSpans: [
|
|
161
188
|
{
|
|
@@ -165,8 +192,8 @@ export function buildTracePayload({ version, name, attributes, startMs, endMs, s
|
|
|
165
192
|
scope: { name: 'lorekit-cli', version: String(version) },
|
|
166
193
|
spans: [
|
|
167
194
|
{
|
|
168
|
-
traceId: randHex(16),
|
|
169
|
-
spanId: randHex(8),
|
|
195
|
+
traceId: traceId ?? randHex(16),
|
|
196
|
+
spanId: spanId ?? randHex(8),
|
|
170
197
|
name,
|
|
171
198
|
kind: 1, // INTERNAL
|
|
172
199
|
startTimeUnixNano: String(startMs * 1_000_000),
|
|
@@ -243,9 +270,9 @@ async function post(url, headers, payload, timeoutMs) {
|
|
|
243
270
|
* can never delay or fail the CLI. Awaited before process exit (Node would
|
|
244
271
|
* otherwise drop the in-flight request), but capped at timeoutMs.
|
|
245
272
|
*/
|
|
246
|
-
export async function exportInvocation(config, { version, name, attributes, startMs, endMs, status, statusMessage }, { timeoutMs = 1500 } = {}) {
|
|
273
|
+
export async function exportInvocation(config, { version, name, attributes, startMs, endMs, status, statusMessage, traceId, spanId }, { timeoutMs = 1500 } = {}) {
|
|
247
274
|
if (!config || !config.enabled) return;
|
|
248
|
-
const trace = buildTracePayload({ version, name, attributes, startMs, endMs, status, statusMessage });
|
|
275
|
+
const trace = buildTracePayload({ version, name, attributes, startMs, endMs, status, statusMessage, traceId, spanId });
|
|
249
276
|
const metric = buildMetricsPayload({ version, attributes, startMs, endMs });
|
|
250
277
|
await Promise.all([
|
|
251
278
|
post(`${config.endpoint}/v1/traces`, config.headers, trace, timeoutMs),
|
|
@@ -297,13 +324,32 @@ export async function traceCommand(command, args, version, run) {
|
|
|
297
324
|
config = { enabled: false };
|
|
298
325
|
}
|
|
299
326
|
|
|
327
|
+
// Generate trace context BEFORE the export gate below, so outgoing REST/MCP
|
|
328
|
+
// calls can forward it as `traceparent` even when telemetry is disabled.
|
|
329
|
+
// Propagation is decoupled from export: a user with no OTLP endpoint (the
|
|
330
|
+
// common case — TELEMETRY_TOKEN is empty in git) still gets a single
|
|
331
|
+
// correlated server-side trace. The `sampled` bit is what reflects export.
|
|
332
|
+
// The same IDs are reused in exportInvocation() to link the CLI span to
|
|
333
|
+
// REST spans.
|
|
334
|
+
_activeTraceId = randHex(16);
|
|
335
|
+
_activeSpanId = randHex(8);
|
|
336
|
+
_activeSampled = Boolean(config.enabled);
|
|
337
|
+
|
|
300
338
|
// Fast path: no export configured → run with zero telemetry overhead. Still
|
|
301
339
|
// normalize the result to a numeric exit code: commands may resolve to an
|
|
302
340
|
// { exitCode, ...extra } object (e.g. `doctor`), and only the instrumented
|
|
303
341
|
// path below unwraps it. Returning `run()` raw would leak that object all the
|
|
304
342
|
// way to `process.exit(obj)` in the bin entry → ERR_INVALID_ARG_TYPE crash
|
|
305
343
|
// (exit 1) for every user without an OTLP endpoint configured.
|
|
306
|
-
if (!config.enabled)
|
|
344
|
+
if (!config.enabled) {
|
|
345
|
+
try {
|
|
346
|
+
return normalizeExitCode(await run());
|
|
347
|
+
} finally {
|
|
348
|
+
_activeTraceId = null;
|
|
349
|
+
_activeSpanId = null;
|
|
350
|
+
_activeSampled = false;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
307
353
|
|
|
308
354
|
const startMs = Date.now();
|
|
309
355
|
let exitCode = 0;
|
|
@@ -360,9 +406,15 @@ export async function traceCommand(command, args, version, run) {
|
|
|
360
406
|
endMs: Date.now(),
|
|
361
407
|
status,
|
|
362
408
|
statusMessage,
|
|
409
|
+
traceId: _activeTraceId,
|
|
410
|
+
spanId: _activeSpanId,
|
|
363
411
|
});
|
|
364
412
|
} catch {
|
|
365
413
|
// never let telemetry break the CLI
|
|
414
|
+
} finally {
|
|
415
|
+
_activeTraceId = null;
|
|
416
|
+
_activeSpanId = null;
|
|
417
|
+
_activeSampled = false;
|
|
366
418
|
}
|
|
367
419
|
}
|
|
368
420
|
}
|