@lorekit/cli 1.21.0 → 1.23.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
@@ -116,7 +116,7 @@ and `global`) — split into two clearly-labelled sections so you can see where
116
116
  each lesson lives:
117
117
 
118
118
  - **Offline** — the local two-tier store (`.lorekit/` in the repo + `~/.lorekit/`).
119
- - **Remote** — the hosted MCP store. When no token/endpoint is configured this
119
+ - **Remote** — the hosted LoreKit store, reached over the REST API. When no token/endpoint is configured this
120
120
  section is a short note on how to set it up; it is **never an error** (the
121
121
  command still exits 0 and shows your offline lessons). A network/server error
122
122
  is likewise a per-scope warning, not a crash.
@@ -192,7 +192,7 @@ which is the point of an overview). An unconfigured remote degrades to a short
192
192
  note — never an error, always exit 0. `--endpoint` / `--token` / `--store`
193
193
  behave as in `list`. Remote counts reflect what the hosted `memory.list` returns
194
194
  per scope (the server's default page size); there is no cap-usage `N / limit`
195
- figure because the MCP surface exposes no total-count or cap tool.
195
+ figure because the REST API exposes no total-count or cap endpoint.
196
196
 
197
197
  ### `lorekit scopes`
198
198
 
@@ -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 not possible, and `scopes` says so honestly.** The
228
- hosted MCP surface exposes no "list all scopes" tool every read tool
229
- (`memory.list` / `memory.search` / `memory.read`) *requires* a scope so a
230
- remote inventory can't be built. The Remote section is therefore always a short
231
- note (never a faked listing), degrading gracefully at exit 0, the same way
232
- `stats` omits a cap-usage figure. `--endpoint` / `--token` / `--store` behave as
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
 
@@ -367,7 +367,7 @@ Memory has a controllable backend. Three **modes**:
367
367
  |------|--------------------|-------|
368
368
  | `off` | nowhere | Memory is disabled — every hook event and store op is a silent no-op. |
369
369
  | `local` | markdown files in two tiers (see below) | **Local means _not_ on the hosted website** — local lessons never sync to the LoreKit dashboard. That is the point of local: private-by-default, greppable, git-native. |
370
- | `remote` | the LoreKit MCP server (hosted) | The shared, cross-machine backend. Reads stay silent until an endpoint + token are configured. This is the default. |
370
+ | `remote` | the hosted LoreKit API (REST) | The shared, cross-machine backend. Reads stay silent until an endpoint + token are configured. This is the default. |
371
371
 
372
372
  ### Local store layout — two tiers
373
373
 
package/bin/lorekit.mjs CHANGED
@@ -45,10 +45,10 @@ ${c.bold('Commands')}
45
45
  the MCP server entry, and the lifecycle hooks for the chosen scope. Surgical —
46
46
  other servers, hooks, and settings are left untouched. Prompts
47
47
  project vs global; --project / --global choose non-interactively.
48
- doctor Verify the skill install, MCP connectivity, token, and scope.
48
+ doctor Verify the skill install, remote connectivity, token, and scope.
49
49
  list (ls) List the memories that apply to the current directory, split into
50
50
  an Offline section (local .lorekit/ + ~/.lorekit/) and a Remote
51
- section (hosted MCP). Groups by scope (project/branch/repo/global).
51
+ section (the hosted LoreKit API). Groups by scope (project/branch/repo/global).
52
52
  --json for scripting, --scope <s> to narrow.
53
53
  search Full-text search the applicable memories across both stores and all
54
54
  (grep) scopes (case-insensitive, literal substring over key + value),
@@ -194,7 +194,7 @@ ${c.bold('Examples')}
194
194
  ${c.bold('Usage')}
195
195
  npx @lorekit/cli doctor [options]
196
196
 
197
- Checks the node runtime, skill install, resolved memory mode, MCP connectivity,
197
+ Checks the node runtime, skill install, resolved memory mode, remote connectivity,
198
198
  token, and scope.
199
199
 
200
200
  ${c.bold('Options')}
@@ -217,8 +217,8 @@ ${c.bold('Usage')}
217
217
 
218
218
  Shows the memories for the scopes that resolve for the current directory
219
219
  (project/branch/repo/global), split into an Offline section (the local
220
- .lorekit/ + ~/.lorekit/ two-tier store) and a Remote section (the hosted MCP
221
- server). When no remote token/endpoint is configured the Remote section is a
220
+ .lorekit/ + ~/.lorekit/ two-tier store) and a Remote section (the hosted LoreKit
221
+ API). When no remote token/endpoint is configured the Remote section is a
222
222
  short note on how to set it up — never an error.
223
223
 
224
224
  ${c.bold('Options')}
@@ -320,7 +320,7 @@ ${c.bold('Usage')}
320
320
 
321
321
  Shows how many memories apply to the current directory's scopes (project/branch/
322
322
  repo/global), broken down per scope and per store (Offline = the local .lorekit/
323
- + ~/.lorekit/ two-tier store; Remote = the hosted MCP server), with per-store and
323
+ + ~/.lorekit/ two-tier store; Remote = the hosted LoreKit API), with per-store and
324
324
  grand totals. An unconfigured remote degrades to a short note, never an error.
325
325
 
326
326
  ${c.bold('Options')}
@@ -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. The Remote section is always a
352
- short note: the hosted MCP surface has no "list all scopes" tool (every read tool
353
- requires a scope), so a remote inventory isn't possible never an error (exit 0).
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lorekit/cli",
3
- "version": "1.21.0",
3
+ "version": "1.23.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": {
@@ -11,10 +11,12 @@
11
11
  // off → advertise no tools; a call reports "disabled"
12
12
  //
13
13
  // org.* tools are always advertised regardless of memory mode. They proxy to
14
- // the remote endpoint because org management requires a server-side session
15
- // (JWT auth, SECURITY DEFINER RPCs). In local/off mode a transient RemoteStore
16
- // is built from the configured endpoint + token. If no remote is configured,
17
- // a clear error is returned.
14
+ // the remote endpoint because orgs are server-side state — there is no local
15
+ // equivalent to serve them from. (They no longer require a Supabase JWT: the
16
+ // store calls the REST `/orgs` routes, which accept `lk_*` API tokens as of
17
+ // 00041_org_actor_override.sql.) In local/off mode a transient RemoteStore is
18
+ // built from the configured endpoint + token. If no remote is configured, a
19
+ // clear error is returned.
18
20
  //
19
21
  // Machine-facing: ONLY JSON-RPC frames go to stdout — any diagnostics go to
20
22
  // stderr. The server never throws on malformed or partial input; a bad frame
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
- export async function mcpCall(endpoint, token, method, params = {}, { timeoutMs = 10000 } = {}) {
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,
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 NOT possible: the hosted MCP surface exposes no "list
17
- // all scopes" tool every read tool (memory.list / search / read) REQUIRES a
18
- // scope so the Remote section is always an honest note (never faked), the same
19
- // way `stats` omits a cap-usage figure. It still degrades gracefully at exit 0.
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: never enumerable. A deny note, an unconfigured note, or the honest
67
- // "not supported" note all graceful (exit 0). Order matches the other
68
- // commands' precedence: deny first, then connectivity, then the capability.
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
- remoteSection = { available: false, reason: REMOTE_SCOPES_UNSUPPORTED };
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 + a boolean) — never a scope
93
- // string, path, or token. `remote_available` is always false: the surface
94
- // can't enumerate, and saying so is the honest signal.
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.remote_available': false,
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
 
@@ -1,10 +1,24 @@
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';
1
+ // Remote store: wraps the LoreKit REST API behind the common store contract.
2
+ // EVERY operation this store performs goes over REST memory list, search,
3
+ // read, write, delete (soft-archive AND `?force=true` hard-delete), the
4
+ // store-wide `listScopes()` enumeration, all four `org.*` operations, and the
5
+ // `ping` connectivity probe. There is NO MCP transport left here: the store
6
+ // imports `restFetch`/`mcpToRestBase` and nothing else.
7
+ //
8
+ // Org ops used to be the one holdout. The org RPCs resolved their actor from
9
+ // `auth.uid()`, which is NULL on the service-role connection an `lk_*` api_key
10
+ // token gets, so `/orgs` 403'd every CLI caller and the store had to keep a
11
+ // JSON-RPC transport alive purely for `org.create`/`org.list`/`org.rename`/
12
+ // `org.delete`. `00041_org_actor_override.sql` added a `p_actor_user_id`
13
+ // override that the RPCs honour only on a verified service_role connection,
14
+ // and the `orgs` edge function now passes it plus its own tenant filters — so
15
+ // every `/orgs*` route serves api_key tokens and the holdout is gone.
16
+ //
17
+ // `packages/cli/src/mcp.mjs` still exists and is still used: it backs the
18
+ // `lorekit mcp` stdio server command and provides `mcpToRestBase`. It is just
19
+ // no longer a transport for this store.
20
+ // Zero-dependency.
21
+ import { restFetch, mcpToRestBase } from '../mcp.mjs';
8
22
  import { getActiveTraceparent } from '../telemetry.mjs';
9
23
 
10
24
  // Drop undefined/null args so JSON payloads stay tidy.
@@ -20,7 +34,12 @@ export function createRemoteStore({ endpoint, token } = {}) {
20
34
 
21
35
  class RemoteStore {
22
36
  constructor(endpoint, token) {
23
- this.endpoint = endpoint; // MCP URL (kept for org ops)
37
+ // The configured endpoint is still spelled as the `/mcp` URL (that is what
38
+ // `.mcp.json` / LOREKIT_MCP_URL hold, and changing that config key is a
39
+ // separate migration). Nothing here speaks MCP: it exists only so
40
+ // `usable()` can judge the configuration and `mcpToRestBase` can derive the
41
+ // REST base from it.
42
+ this.endpoint = endpoint;
24
43
  this.token = token;
25
44
  this.restBase = mcpToRestBase(endpoint); // REST base URL for memory ops
26
45
  this.mode = 'remote';
@@ -37,22 +56,6 @@ class RemoteStore {
37
56
  return restFetch(this.restBase, this.token, path, { ...opts, traceparent: this._tp() });
38
57
  }
39
58
 
40
- async _mcp(name, args) {
41
- if (!this.usable()) return { ok: false, unusable: true };
42
- return mcpCall(this.endpoint, this.token, 'tools/call', { name, arguments: args });
43
- }
44
-
45
- _mcpEntries(res) {
46
- if (!res.ok) return { ok: false, error: res.error, networkError: res.networkError };
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 : [] };
54
- }
55
-
56
59
  // ── Memory operations → REST ──────────────────────────────────────────────
57
60
 
58
61
  async list({ scope, tags, limit } = {}) {
@@ -79,6 +82,8 @@ class RemoteStore {
79
82
  const p = new URLSearchParams();
80
83
  if (scope) p.set('scope', scope);
81
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');
82
87
  const res = await this._rest(`/memories?${p}`);
83
88
  if (!res.ok) return { ok: false, error: res.error, networkError: res.networkError };
84
89
  const entries = res.data?.entries ?? [];
@@ -99,14 +104,12 @@ class RemoteStore {
99
104
  return { ok: res.ok, error: res.error, networkError: res.networkError };
100
105
  }
101
106
 
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).
102
110
  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
111
  const p = new URLSearchParams({ scope, key });
112
+ if (force) p.set('force', 'true');
110
113
  const res = await this._rest(`/memories?${p}`, { method: 'DELETE' });
111
114
  return { ok: res.ok, error: res.error, networkError: res.networkError };
112
115
  }
@@ -116,66 +119,94 @@ class RemoteStore {
116
119
  return this.delete({ scope, key, force: false });
117
120
  }
118
121
 
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.
126
-
122
+ // ── Org operations → REST ─────────────────────────────────────────────────
123
+ // `supabase/functions/orgs/` serves `lk_*` tokens on every route as of
124
+ // 00041_org_actor_override.sql (see the file header). Each method's RETURN
125
+ // SHAPE is unchanged from the MCP era `packages/cli/src/mcp-server.mjs`
126
+ // serialises these objects straight into a `tools/call` result, so the shape
127
+ // is a published contract, not an internal detail.
128
+ //
129
+ // Slugs are interpolated into the PATH, so they must be encodeURIComponent'd.
130
+ // A slug is server-side constrained to `[a-z0-9-]`, but the CLI passes
131
+ // whatever the user typed and an un-encoded `../` or `?` would retarget the
132
+ // request at a different route entirely.
133
+
134
+ // POST /orgs → 201 with the created org object, `{ id, slug, name, created_at }`.
135
+ // The handler reads the row back after the RPC (`orgs/handlers/orgs/create.ts`);
136
+ // `created_at` is absent only on the read-back miss, where it falls back to
137
+ // `{ id, slug, name }`. A bare JSON id string is the LEGACY shape an older
138
+ // deployed backend can still return — that is what the string branch below is
139
+ // for, not the normal case. Either way this method reassembles the
140
+ // `{ id, slug, name }` triple, because `packages/cli/src/mcp-server.mjs`
141
+ // serialises it straight into a `tools/call` result and that shape is a
142
+ // published contract.
127
143
  async orgCreate({ slug, name } = {}) {
128
- const res = await this._mcp('org.create', { slug, name });
144
+ const res = await this._rest('/orgs', { method: 'POST', body: { slug, name } });
129
145
  if (!res.ok) return { ok: false, error: res.error, networkError: res.networkError };
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; }
134
- return { ok: true, org: payload };
146
+ const id = typeof res.data === 'string' ? res.data : (res.data?.id ?? null);
147
+ return { ok: true, org: { id, slug, name } };
135
148
  }
136
149
 
150
+ // GET /orgs → { entries: [{ id, slug, name, role, created_at }] }
137
151
  async orgList() {
138
- const res = await this._mcp('org.list', {});
139
- return this._mcpEntries(res);
152
+ const res = await this._rest('/orgs');
153
+ if (!res.ok) return { ok: false, error: res.error, networkError: res.networkError };
154
+ return { ok: true, entries: Array.isArray(res.data?.entries) ? res.data.entries : [] };
140
155
  }
141
156
 
157
+ // PATCH /orgs/:slug → 200 { slug, name }
142
158
  async orgRename({ slug, name } = {}) {
143
- const res = await this._mcp('org.rename', { slug, name });
159
+ const res = await this._rest(`/orgs/${encodeURIComponent(slug)}`, { method: 'PATCH', body: { name } });
144
160
  if (!res.ok) return { ok: false, error: res.error, networkError: res.networkError };
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 ?? {}) };
161
+ return { ok: true, ...(res.data ?? { slug, name }) };
148
162
  }
149
163
 
164
+ // DELETE /orgs/:slug → 204 with no body, so `deleted: true` is synthesised to
165
+ // preserve the `{ ok, deleted, slug }` shape the MCP tool returned.
150
166
  async orgDelete({ slug } = {}) {
151
- const res = await this._mcp('org.delete', { slug });
167
+ const res = await this._rest(`/orgs/${encodeURIComponent(slug)}`, { method: 'DELETE' });
152
168
  if (!res.ok) return { ok: false, error: res.error, networkError: res.networkError };
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 ?? {}) };
156
- }
157
-
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.
169
+ return { ok: true, deleted: true, slug };
170
+ }
171
+
172
+ // Store-wide scope enumeration — every distinct scope the caller can see with
173
+ // its count of active (non-archived, non-expired) memories. `GET
174
+ // /memories/scopes` aggregates in Postgres, so the answer is exact at any size
175
+ // (see supabase/functions/memories/handlers/scopes.ts).
176
+ //
177
+ // The `scopes` array is the SAME `[{ scope, count }]` inventory shape
178
+ // `LocalStore.listScopes()` returns, so `scopes.mjs` feeds both through the
179
+ // same pure `filterScopeInventory`/`summarizeScopeInventory` helpers. Ordering
180
+ // is not relied upon (the server sorts by scope asc; the view re-sorts by
181
+ // scope type). Failures use this store's standard `{ ok:false, error,
182
+ // networkError }` envelope so the caller can degrade gracefully.
162
183
  async listScopes() {
163
- return { ok: false, unsupported: true };
184
+ const res = await this._rest('/memories/scopes');
185
+ if (!res.ok) return { ok: false, error: res.error, networkError: res.networkError, unusable: res.unusable };
186
+ const scopes = Array.isArray(res.data?.scopes) ? res.data.scopes : [];
187
+ return { ok: true, scopes: scopes.map((s) => ({ scope: s.scope, count: Number(s.count) || 0 })) };
164
188
  }
165
189
 
166
190
  // Connectivity probe for doctor — a transport check, not a memory op.
191
+ //
192
+ // There is no MCP fallback: a `restBase` we could not derive means the
193
+ // configured endpoint is not a URL, and a JSON-RPC POST to that same
194
+ // unparseable string could only fail in a less legible way. Report the
195
+ // configuration problem instead.
167
196
  async ping() {
168
197
  if (!this.usable()) return { ok: false, unusable: true };
169
198
  // 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) }; }
199
+ if (!this.restBase) {
200
+ return { ok: false, error: { message: `Endpoint is not a valid URL: ${this.endpoint}` } };
178
201
  }
179
- return mcpCall(this.endpoint, this.token, 'tools/list', {});
202
+ const healthUrl = `${this.restBase.replace(/\/functions\/v1$/, '')}/functions/v1/health`;
203
+ const tp = this._tp();
204
+ try {
205
+ const res = await fetch(healthUrl, {
206
+ signal: AbortSignal.timeout(5000),
207
+ ...(tp ? { headers: { traceparent: tp } } : {}),
208
+ });
209
+ return { ok: res.ok, httpStatus: res.status };
210
+ } catch (e) { return { ok: false, networkError: String(e?.message ?? e) }; }
180
211
  }
181
212
  }
package/src/telemetry.mjs CHANGED
@@ -41,19 +41,30 @@ const FLAG_ATTRS = ['global', 'project', 'deep', 'yes', 'force', 'no-hooks', 'js
41
41
  const OFF_VALUES = new Set(['0', 'off', 'false', 'no', 'disable', 'disabled']);
42
42
 
43
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.
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.
46
48
  let _activeTraceId = null;
47
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;
48
53
 
49
54
  /**
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.
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.
53
64
  */
54
65
  export function getActiveTraceparent() {
55
66
  if (!_activeTraceId || !_activeSpanId) return null;
56
- return `00-${_activeTraceId}-${_activeSpanId}-01`;
67
+ return `00-${_activeTraceId}-${_activeSpanId}-${_activeSampled ? '01' : '00'}`;
57
68
  }
58
69
 
59
70
  // ── Config resolution ─────────────────────────────────────────────────────────
@@ -313,18 +324,32 @@ export async function traceCommand(command, args, version, run) {
313
324
  config = { enabled: false };
314
325
  }
315
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
+
316
338
  // Fast path: no export configured → run with zero telemetry overhead. Still
317
339
  // normalize the result to a numeric exit code: commands may resolve to an
318
340
  // { exitCode, ...extra } object (e.g. `doctor`), and only the instrumented
319
341
  // path below unwraps it. Returning `run()` raw would leak that object all the
320
342
  // way to `process.exit(obj)` in the bin entry → ERR_INVALID_ARG_TYPE crash
321
343
  // (exit 1) for every user without an OTLP endpoint configured.
322
- if (!config.enabled) return normalizeExitCode(await run());
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);
344
+ if (!config.enabled) {
345
+ try {
346
+ return normalizeExitCode(await run());
347
+ } finally {
348
+ _activeTraceId = null;
349
+ _activeSpanId = null;
350
+ _activeSampled = false;
351
+ }
352
+ }
328
353
 
329
354
  const startMs = Date.now();
330
355
  let exitCode = 0;
@@ -389,6 +414,7 @@ export async function traceCommand(command, args, version, run) {
389
414
  } finally {
390
415
  _activeTraceId = null;
391
416
  _activeSpanId = null;
417
+ _activeSampled = false;
392
418
  }
393
419
  }
394
420
  }