@lorekit/cli 1.53.0 → 1.55.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.
@@ -31,191 +31,17 @@ import { createRemoteStore } from './store/remote.mjs';
31
31
  import { deriveOrigin, mergeOrigin } from './origin.mjs';
32
32
  import { readScopeInventory } from './store/scope-inventory.mjs';
33
33
  import { inferKindHostFromTags } from './lessons-view.mjs';
34
+ // Generated from packages/schemas/src/tool-catalog.ts — the same declaration the
35
+ // hosted MCP server renders `tools/list` from. Committed and zero-dep because
36
+ // this is a published package that cannot import the workspace schemas.
37
+ import { MCP_TOOL_DEFS, MCP_TOOL_NAMES } from './surfaces.generated.mjs';
34
38
 
35
39
  const PROTOCOL_VERSION = '2024-11-05';
36
40
  const SERVER_INFO = { name: 'lorekit-local', version: '1.0.0' };
37
41
 
38
- // Tool advertisements names + input schemas mirror the production MCP server
39
- // (supabase/functions/mcp/mcp-handler.ts) so a client sees the same contract
40
- // whether it points at the hosted endpoint or this local server.
41
- export const MEMORY_TOOL_DEFS = [
42
- {
43
- name: 'memory.write',
44
- description: 'Store or update a memory',
45
- inputSchema: {
46
- type: 'object',
47
- required: ['scope', 'key', 'value'],
48
- properties: {
49
- scope: { type: 'string' },
50
- key: { type: 'string' },
51
- value: { type: 'string' },
52
- tags: { type: 'array', items: { type: 'string' } },
53
- source_agent: { type: 'string' },
54
- trigger: { type: 'string' },
55
- created_at: {
56
- type: 'string',
57
- format: 'date-time',
58
- description:
59
- 'Optional ISO 8601 creation date for migrating a pre-existing memory. Rejected if invalid or in the future. Applies only when the memory is first created.',
60
- },
61
- ttl_days: {
62
- type: 'integer',
63
- minimum: 1,
64
- maximum: 365,
65
- description:
66
- 'Optional time-to-live in days (1–365). The memory auto-expires that many days after this write and is then hidden from reads.',
67
- },
68
- clear_ttl: {
69
- type: 'boolean',
70
- description:
71
- 'Remove any existing expiry, making the memory permanent again. Takes precedence over ttl_days when both are supplied.',
72
- },
73
- origin_repo: {
74
- type: 'string',
75
- description:
76
- 'Provenance: the owner/name of the repository this memory was recorded from. Derived from the working directory when omitted.',
77
- },
78
- origin_branch: {
79
- type: 'string',
80
- description:
81
- 'Provenance: the git branch this memory was recorded from. Derived from the working directory when omitted.',
82
- },
83
- origin_commit: {
84
- type: 'string',
85
- description:
86
- 'Provenance: the commit SHA checked out when this memory was recorded. Derived from the working directory when omitted.',
87
- },
88
- origin_pr: {
89
- type: 'integer',
90
- minimum: 1,
91
- description:
92
- 'Provenance: the pull request number this memory came out of. Pass it when you know it — the server can only infer it from CI environment variables.',
93
- },
94
- },
95
- },
96
- },
97
- {
98
- name: 'memory.read',
99
- description: 'Read a memory by scope and key',
100
- inputSchema: { type: 'object', required: ['scope', 'key'] },
101
- },
102
- {
103
- name: 'memory.list',
104
- description: 'List memories for a scope',
105
- inputSchema: {
106
- type: 'object',
107
- required: ['scope'],
108
- properties: {
109
- scope: { type: 'string' },
110
- tags: { type: 'array', items: { type: 'string' } },
111
- limit: { type: 'integer', minimum: 1, maximum: 100, default: 50 },
112
- cursor: { type: 'string', description: 'Opaque cursor from a previous response\'s nextCursor. Omit to start from the first page. Ignored when kind or host is set — a taxonomy-filtered list is a single bounded page (nextCursor is always null); raise limit rather than paginating.' },
113
- kind: { type: 'string', enum: ['lesson', 'bus', 'signal'], description: 'Filter to one bucket family. Narrowed server-side against the remote store; post-filtered client-side against the local store, whose rows carry no kind/host columns and are classified from their loop:: tag.' },
114
- host: { type: 'string', description: 'Filter to the owning skill or agent, e.g. `reviewer`. Same server-side/client-side split as kind.' },
115
- view: { type: 'string', enum: ['full', 'summary'], default: 'full', description: 'summary omits each entry\'s value and returns value_bytes + a 200-character preview instead.' },
116
- },
117
- },
118
- },
119
- {
120
- name: 'memory.search',
121
- description: 'Keyword search across memories',
122
- inputSchema: {
123
- type: 'object',
124
- required: ['q'],
125
- properties: {
126
- q: { type: 'string' },
127
- scopes: { type: 'array', items: { type: 'string' } },
128
- tags: { type: 'array', items: { type: 'string' } },
129
- limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
130
- cursor: { type: 'string', description: 'Opaque cursor from a previous response\'s nextCursor. Omit to start from the first page.' },
131
- },
132
- },
133
- },
134
- {
135
- name: 'memory.delete',
136
- description:
137
- 'Soft-archive a memory (default) or hard-delete it (force: true). ' +
138
- 'Archived memories are hidden from reads but can be restored.',
139
- inputSchema: {
140
- type: 'object',
141
- required: ['scope', 'key'],
142
- properties: {
143
- scope: { type: 'string' },
144
- key: { type: 'string' },
145
- force: { type: 'boolean' },
146
- },
147
- },
148
- },
149
- {
150
- name: 'memory.archive',
151
- description: 'Soft-archive a memory. Hidden from reads but restorable.',
152
- inputSchema: { type: 'object', required: ['scope', 'key'] },
153
- },
154
- {
155
- name: 'memory.scopes',
156
- // The description tells the model WHEN to reach for this, not just what it
157
- // returns: every other read tool needs a scope named up front, so this is
158
- // the one that answers "what is there?" before you can ask "what is in it?".
159
- description:
160
- 'List every scope in the store with how many active memories it holds — '
161
- + 'the inventory to consult when you do not already know which scope to read. '
162
- + 'Takes no arguments and is store-wide, NOT limited to the current directory.',
163
- inputSchema: { type: 'object', properties: {} },
164
- },
165
- ];
166
-
167
- // Org tools — always advertised regardless of memory mode. They always route
168
- // through the remote MCP endpoint because org management requires JWT auth
169
- // (SECURITY DEFINER RPCs resolve actor via auth.uid(), never a passed user_id).
170
- export const ORG_TOOL_DEFS = [
171
- {
172
- name: 'org.create',
173
- description:
174
- 'Create a new organization. You become its owner automatically. ' +
175
- 'The slug must be globally unique and lowercase (letters, digits, hyphens).',
176
- inputSchema: {
177
- type: 'object',
178
- required: ['slug', 'name'],
179
- properties: {
180
- slug: { type: 'string', description: 'Unique lowercase identifier, e.g. "my-team"' },
181
- name: { type: 'string', description: 'Human-readable display name' },
182
- },
183
- },
184
- },
185
- {
186
- name: 'org.list',
187
- description: 'List all organizations you are a member of, with your role in each.',
188
- inputSchema: { type: 'object', properties: {} },
189
- },
190
- {
191
- name: 'org.rename',
192
- description: 'Rename an organization\'s display name. Requires admin or owner role.',
193
- inputSchema: {
194
- type: 'object',
195
- required: ['slug', 'name'],
196
- properties: {
197
- slug: { type: 'string', description: 'The org slug to update' },
198
- name: { type: 'string', description: 'New display name' },
199
- },
200
- },
201
- },
202
- {
203
- name: 'org.delete',
204
- description:
205
- 'Permanently delete an organization. Requires owner role. ' +
206
- 'All org-owned memories and memberships are cascade-deleted. Unrecoverable.',
207
- inputSchema: {
208
- type: 'object',
209
- required: ['slug'],
210
- properties: {
211
- slug: { type: 'string', description: 'The org slug to delete' },
212
- },
213
- },
214
- },
215
- ];
216
-
217
- // Legacy alias kept so existing code that imports TOOL_DEFS still compiles.
218
- export const TOOL_DEFS = [...MEMORY_TOOL_DEFS, ...ORG_TOOL_DEFS];
42
+ // Tool advertisements are DERIVED from the canonical catalog rather than
43
+ // restated here see the derivation below the dispatch maps, which is where
44
+ // the set of dispatchable ops is known.
219
45
 
220
46
  /** Characters of `value` echoed in a `view: "summary"` entry's `preview`. */
221
47
  export const LIST_PREVIEW_CHARS = 200;
@@ -398,6 +224,12 @@ const MEMORY_DISPATCH = {
398
224
  'memory.search': (store, a) => store.search(a),
399
225
  'memory.delete': (store, a) => store.delete(a),
400
226
  'memory.archive': (store, a) => store.archive(a),
227
+ // The counterpart to archive. Both stores have implemented `restore` all
228
+ // along (local.mjs, remote.mjs, and the two-tier store that fronts them), so
229
+ // its absence here left an agent able to archive a lesson through this server
230
+ // but not undo it — with no stated reason. Surfaced by giving the catalog a
231
+ // `localMcpExempt` field and finding this op had no honest reason to fill it.
232
+ 'memory.restore': (store, a) => store.restore(a),
401
233
  'memory.scopes': (store) => listScopes(store),
402
234
  };
403
235
 
@@ -492,6 +324,61 @@ const ORG_DISPATCH = {
492
324
  'org.delete': (remote, a) => remote.orgDelete(a),
493
325
  };
494
326
 
327
+ // ── Tool advertisements, derived ─────────────────────────────────────────────
328
+ // These used to be ~180 lines of hand-written `name` + `description` +
329
+ // `inputSchema` literals — a THIRD copy of the tool list, after the catalog and
330
+ // the edge handler, that nothing cross-checked. It had already drifted: the
331
+ // descriptions said "memory" where the catalog says "lesson", most properties
332
+ // carried no description at all, and `org.delete` claimed org lore was
333
+ // "cascade-deleted. Unrecoverable." when the edge in fact SOFT-deletes it.
334
+ //
335
+ // Deriving from the same projection the hosted server advertises (`wireTools()`)
336
+ // makes the local and hosted contracts identical by construction, which was the
337
+ // stated intent of the copy all along.
338
+ //
339
+ // Advertise only what we can actually DISPATCH: the dispatch maps are the
340
+ // hand-written half (a name cannot become a function by itself), so they decide
341
+ // membership and the catalog supplies the content. An op the catalog declares
342
+ // but this server does not back carries a `localMcpExempt` reason there.
343
+ const CATALOG_DEFS_BY_NAME = new Map(MCP_TOOL_DEFS.map((def) => [def.name, def]));
344
+
345
+ /** Catalog order, so the advertisement lists ops the way `tools/list` does. */
346
+ const CATALOG_ORDER = new Map(MCP_TOOL_NAMES.map((name, index) => [name, index]));
347
+
348
+ /**
349
+ * Resolve dispatchable names to their catalog advertisement, in catalog order.
350
+ *
351
+ * Driven by the DISPATCH keys, not by the catalog. Iterating the catalog and
352
+ * filtering to what is dispatchable reads more naturally and is wrong: every
353
+ * name would then be a catalog name by construction, so the check below could
354
+ * never fire, and the case it exists for — a dispatch key the catalog does not
355
+ * declare — would instead be SILENTLY DROPPED from `tools/list` while
356
+ * `tools/call` went on serving it. An op that is served but not advertised is
357
+ * precisely the drift this file stopped hand-maintaining its defs to avoid.
358
+ *
359
+ * Exported so the failure is directly testable; nothing else calls it.
360
+ */
361
+ export function advertise(dispatch) {
362
+ return Object.keys(dispatch)
363
+ .map((name) => {
364
+ const def = CATALOG_DEFS_BY_NAME.get(name);
365
+ if (!def) {
366
+ throw new Error(
367
+ `mcp-server dispatches "${name}", which the tool catalog does not declare. `
368
+ + 'Add it to packages/schemas/src/tool-catalog.ts (and regenerate: node scripts/gen-surfaces.mjs).',
369
+ );
370
+ }
371
+ return def;
372
+ })
373
+ .sort((a, b) => CATALOG_ORDER.get(a.name) - CATALOG_ORDER.get(b.name));
374
+ }
375
+
376
+ export const MEMORY_TOOL_DEFS = advertise(MEMORY_DISPATCH);
377
+ export const ORG_TOOL_DEFS = advertise(ORG_DISPATCH);
378
+
379
+ // Legacy alias kept so existing code that imports TOOL_DEFS still compiles.
380
+ export const TOOL_DEFS = [...MEMORY_TOOL_DEFS, ...ORG_TOOL_DEFS];
381
+
495
382
  function reply(id, result) {
496
383
  return { jsonrpc: '2.0', id, result };
497
384
  }
package/src/mcp.mjs CHANGED
@@ -26,6 +26,20 @@ export function buildRemoteUrl(endpoint, token) {
26
26
 
27
27
  let idCounter = 0;
28
28
 
29
+ /**
30
+ * Response header naming the account the request authenticated as, set by the
31
+ * edge REST router for every non-service-role caller.
32
+ *
33
+ * The CLI's only way to know its own account id without a dedicated `/me`
34
+ * round-trip: it rides along on calls the CLI was making anyway. Cached by
35
+ * `RemoteStore._rest` so LOCAL, fully-offline runs can still report which
36
+ * account they belong to — see `telemetry-identity.mjs`.
37
+ *
38
+ * Kept in step with `CALLER_USER_ID_HEADER` in
39
+ * `supabase/functions/_shared/api/router.ts`.
40
+ */
41
+ export const USER_ID_HEADER = 'x-lorekit-user-id';
42
+
29
43
  // Returns { ok, httpStatus, result, error, networkError }.
30
44
  //
31
45
  // `opts.traceparent` is an optional W3C traceparent header value (see
@@ -223,10 +237,20 @@ export async function restFetch(baseUrl, token, path, { method = 'GET', body, ti
223
237
  const text = await res.text();
224
238
  let data = null;
225
239
  try { data = text ? JSON.parse(text) : null; } catch { /* non-JSON body */ }
240
+ // The account this call authenticated as, as the server resolved it. Read on
241
+ // BOTH the success and failure paths: a 429 or a 404 is still an
242
+ // authenticated request, and rate-limited traffic is exactly when knowing
243
+ // whose it is matters most. Surfaced, not cached, here — `mcp.mjs` is in
244
+ // `control.mjs`'s import graph (`splitEndpoint`), and the identity module
245
+ // reads `homeRoot` from `control.mjs`, so importing it here would close an
246
+ // import cycle. `RemoteStore._rest` — the ONE caller of this function, so
247
+ // nothing is missed by doing it a layer out — does the caching.
248
+ const userId = res.headers?.get?.(USER_ID_HEADER) ?? null;
226
249
  if (!res.ok) {
227
250
  return {
228
251
  ok: false,
229
252
  httpStatus: res.status,
253
+ userId,
230
254
  // How long the server asked the caller to wait, in seconds, or null when
231
255
  // it did not say. Only a 429 carries one today (`tooManyRequests` sets
232
256
  // BOTH a `retryAfterSeconds` body field and the `Retry-After` header),
@@ -236,7 +260,7 @@ export async function restFetch(baseUrl, token, path, { method = 'GET', body, ti
236
260
  error: data?.error ? { message: data.error, code: data.code } : { code: res.status, message: text.slice(0, 200) || res.statusText },
237
261
  };
238
262
  }
239
- return { ok: true, httpStatus: res.status, data };
263
+ return { ok: true, httpStatus: res.status, data, userId };
240
264
  } catch (e) {
241
265
  return { ok: false, networkError: String(e?.message ?? e) };
242
266
  } finally {
package/src/purge.mjs ADDED
@@ -0,0 +1,191 @@
1
+ // `lorekit purge` / `lorekit purge-expired` — the two maintenance sweeps.
2
+ //
3
+ // purge [--retention-days N] permanently delete archived lore older than N days
4
+ // purge-expired permanently delete TTL-expired lore
5
+ //
6
+ // Both are IRREVERSIBLE and account-wide: they carry no scope, and the row set
7
+ // is chosen inside the RPC rather than by anything the caller passes. Three
8
+ // consequences shape this module.
9
+ //
10
+ // 1. REMOTE ONLY. These sweep server-side state; the offline `.lorekit/` store
11
+ // has no equivalent operation. `--local` is refused explicitly rather than
12
+ // silently succeeding against a store it cannot affect.
13
+ //
14
+ // 2. NO DRY RUN IS POSSIBLE. The purge RPCs return their count only AFTER
15
+ // deleting, and the REST dry-run header stops before the write and returns
16
+ // nothing previewable — so "would purge N" cannot be answered honestly. The
17
+ // gate is therefore a confirmation, not a preview: prompt on an interactive
18
+ // terminal, and REQUIRE `--yes` when there is nobody to ask (a pipe, CI, or
19
+ // `--json`). An agent loop must not be able to trigger one by omission.
20
+ //
21
+ // 3. A SCOPED KEY IS REFUSED BY THE SERVER, and that refusal is passed through
22
+ // verbatim. `_shared/account-wide-tools.ts` refuses these two operations for
23
+ // any token carrying a scope allowlist — a key narrowed to one repo has no
24
+ // business sweeping the whole account. The CLI makes exactly one request and
25
+ // reports the server's answer; it never retries, never splits the sweep and
26
+ // never re-scopes to work around it.
27
+ import { resolveProjectRoot } from './config.mjs';
28
+ import { loadControl, resolveDenies } from './control.mjs';
29
+ import { resolveStores, remoteUnavailableReason } from './stores.mjs';
30
+ import { log, err, c, select } from './util.mjs';
31
+ import { PURGE_RETENTION_DAYS_DEFAULT } from './surfaces.generated.mjs';
32
+
33
+ /** The window `retention_days` accepts, mirroring `PurgeMemoriesBodySchema`. */
34
+ export const RETENTION_DAYS_MIN = 1;
35
+ export const RETENTION_DAYS_MAX = 365;
36
+
37
+ export { PURGE_RETENTION_DAYS_DEFAULT };
38
+
39
+ /**
40
+ * Validate `--retention-days` before any request goes out.
41
+ *
42
+ * Client-side on purpose: the server would answer an out-of-range value with a
43
+ * schema 4xx, and paying a round trip to be told "365 is the maximum" is a
44
+ * worse experience than being told immediately. Pure, so the boundaries are
45
+ * directly testable.
46
+ */
47
+ export function parseRetentionDays(raw) {
48
+ if (raw === undefined || raw === null || raw === '') {
49
+ return { days: PURGE_RETENTION_DAYS_DEFAULT };
50
+ }
51
+ // Reject `12abc` and `1.5` rather than coercing: a value only half understood
52
+ // is a value the caller did not mean.
53
+ if (!/^\d+$/.test(String(raw).trim())) {
54
+ return { error: `--retention-days must be a whole number, got ${JSON.stringify(String(raw))}` };
55
+ }
56
+ const days = Number(String(raw).trim());
57
+ if (days < RETENTION_DAYS_MIN || days > RETENTION_DAYS_MAX) {
58
+ return { error: `--retention-days must be between ${RETENTION_DAYS_MIN} and ${RETENTION_DAYS_MAX}, got ${days}` };
59
+ }
60
+ return { days };
61
+ }
62
+
63
+ /**
64
+ * Decide how to gate an irreversible sweep.
65
+ *
66
+ * `proceed` — the caller said `--yes`.
67
+ * `prompt` — a human is present, so ask.
68
+ * `refuse` — nobody is present to ask and consent was not given. Refusing is
69
+ * the only safe answer: defaulting to yes would let an unattended
70
+ * agent loop purge, and defaulting to no while reporting success
71
+ * would lie about what happened.
72
+ *
73
+ * `--json` counts as non-interactive even on a TTY: its caller is a script
74
+ * parsing stdout, and a prompt would corrupt that stream.
75
+ */
76
+ export function confirmationDecision({ yes, json, isTTY }) {
77
+ if (yes) return 'proceed';
78
+ if (json || !isTTY) return 'refuse';
79
+ return 'prompt';
80
+ }
81
+
82
+ /**
83
+ * Render a failed sweep.
84
+ *
85
+ * A 403 is almost always the account-wide refusal for a scoped key, and the
86
+ * server's own sentence explains it better than any translation — so it is
87
+ * printed VERBATIM and only a next step is added. Collapsing it into a generic
88
+ * "purge failed" is what would leave someone re-running the command with the
89
+ * same key, which is the one thing that cannot work.
90
+ */
91
+ export function describeFailure({ error, httpStatus, networkError } = {}) {
92
+ if (networkError) return { message: networkError, hint: null };
93
+ const message = error?.message ?? error ?? 'the server rejected the request';
94
+ if (httpStatus === 403) {
95
+ return {
96
+ message: String(message),
97
+ hint: 'Maintenance sweeps need an UNSCOPED token — a key restricted to specific scopes is refused for account-wide operations.',
98
+ };
99
+ }
100
+ if (httpStatus === 429) {
101
+ return { message: String(message), hint: 'Rate limited. Wait for the window to reset and run it again.' };
102
+ }
103
+ return { message: String(message), hint: null };
104
+ }
105
+
106
+ /** Resolve the remote store, or explain why there isn't one. */
107
+ function pickRemote({ root, env, args }) {
108
+ if (args.local) {
109
+ return {
110
+ error:
111
+ 'purge is a remote maintenance operation — it sweeps server-side lore, '
112
+ + 'and the offline store has no equivalent. Drop --local to run it against the API.',
113
+ };
114
+ }
115
+ const { remoteDenied } = resolveDenies(root, { env });
116
+ const { remote, connection } = resolveStores(root, { env, endpoint: args.endpoint, token: args.token });
117
+ if (remoteDenied) return { error: `remote store is disabled by deny constraint (${remoteDenied.source})` };
118
+ if (!remote.usable()) return { error: `remote store is not configured — ${remoteUnavailableReason(connection)}` };
119
+ return { store: remote };
120
+ }
121
+
122
+ // One implementation for both verbs; `op` selects the store method, the label
123
+ // and whether `--retention-days` applies.
124
+ async function run(args, op) {
125
+ const root = resolveProjectRoot(args.dir);
126
+ const env = process.env;
127
+ loadControl(root, { env });
128
+
129
+ const retention = op === 'purge' ? parseRetentionDays(args['retention-days']) : { days: null };
130
+ if (retention.error) {
131
+ err(`${c.red('Error:')} ${retention.error}`);
132
+ return 1;
133
+ }
134
+
135
+ const picked = pickRemote({ root, env, args });
136
+ if (picked.error) {
137
+ err(`${c.red('Error:')} ${picked.error}`);
138
+ return 1;
139
+ }
140
+
141
+ const what = op === 'purge'
142
+ ? `archived lore older than ${retention.days} day${retention.days === 1 ? '' : 's'}`
143
+ : 'all TTL-expired lore';
144
+
145
+ const decision = confirmationDecision({ yes: args.yes, json: args.json, isTTY: Boolean(process.stdin.isTTY) });
146
+ if (decision === 'refuse') {
147
+ err(`${c.red('Refusing:')} ${op} permanently deletes ${what} and cannot be undone.`);
148
+ err(`Re-run with ${c.cyan('--yes')} to confirm. (Required when there is no terminal to prompt.)`);
149
+ return 1;
150
+ }
151
+ if (decision === 'prompt') {
152
+ const go = await select(
153
+ `${c.bold('Permanently delete')} ${what}? This cannot be undone.`,
154
+ [
155
+ { value: false, label: 'Cancel', hint: 'nothing is deleted' },
156
+ { value: true, label: `Yes, ${op}`, hint: 'irreversible' },
157
+ ],
158
+ { defaultIndex: 0 },
159
+ );
160
+ if (!go) {
161
+ log(`${c.dim('Cancelled — nothing was deleted.')}`);
162
+ return 0;
163
+ }
164
+ }
165
+
166
+ const res = op === 'purge'
167
+ ? await picked.store.purge({ retentionDays: retention.days })
168
+ : await picked.store.purgeExpired();
169
+
170
+ if (!res?.ok) {
171
+ const { message, hint } = describeFailure(res);
172
+ if (args.json) {
173
+ log(JSON.stringify({ ok: false, op, purged: null, error: message, httpStatus: res?.httpStatus ?? null }, null, 2));
174
+ return 1;
175
+ }
176
+ err(`${c.red('Error:')} ${message}`);
177
+ if (hint) err(hint);
178
+ return 1;
179
+ }
180
+
181
+ if (args.json) {
182
+ log(JSON.stringify({ ok: true, op, purged: res.purged ?? 0, error: null }, null, 2));
183
+ return 0;
184
+ }
185
+ const n = res.purged ?? 0;
186
+ log(`${c.green('✓')} purged ${c.cyan(String(n))} ${n === 1 ? 'memory' : 'memories'} ${c.dim('(remote)')}`);
187
+ return 0;
188
+ }
189
+
190
+ export function purge(args) { return run(args, 'purge'); }
191
+ export function purgeExpired(args) { return run(args, 'purge-expired'); }
@@ -19,6 +19,7 @@
19
19
  // no longer a transport for this store.
20
20
  // Zero-dependency.
21
21
  import { restFetch, mcpToRestBase } from '../mcp.mjs';
22
+ import { rememberAccountId } from '../telemetry-identity.mjs';
22
23
  import { getActiveTraceparent } from '../telemetry.mjs';
23
24
  import { withReadFields } from './entry-fields.mjs';
24
25
  import { normalizeCreatedAt } from './created-at.mjs';
@@ -139,7 +140,26 @@ class RemoteStore {
139
140
 
140
141
  async _rest(path, opts = {}) {
141
142
  if (!this.usable()) return { ok: false, unusable: true };
142
- return restFetch(this.restBase, this.token, path, { ...opts, traceparent: this._tp() });
143
+ const res = await restFetch(this.restBase, this.token, path, { ...opts, traceparent: this._tp() });
144
+ // Learn (and persist) which account this token belongs to, from the
145
+ // `X-LoreKit-User-Id` header the edge sets on every authenticated response.
146
+ // This is the ONE choke point for the CLI's remote traffic, so a single call
147
+ // site covers every command.
148
+ //
149
+ // Why cache it at all, when the server already knows: a run that never
150
+ // leaves the machine (`--offline`, the local two-tier store, `lorekit hook`)
151
+ // makes no request, so there is no server-side span to carry `auth.user_id`.
152
+ // Persisting it here is what lets THOSE runs report an account and join to
153
+ // server-side `usage_events` / `auth.user_id`.
154
+ //
155
+ // `rememberAccountId` is total, is a no-op when the value is unchanged, and
156
+ // never creates the identity file — so a user who opted out of telemetry
157
+ // gets nothing written even though they still receive the header. Guarded
158
+ // anyway so a store operation can never fail on a telemetry concern.
159
+ try {
160
+ rememberAccountId(res?.userId);
161
+ } catch { /* identity is an enrichment, never a precondition */ }
162
+ return res;
143
163
  }
144
164
 
145
165
  // ── Memory operations → REST ──────────────────────────────────────────────
@@ -516,6 +536,65 @@ class RemoteStore {
516
536
  return { ok: res.ok, error: res.error, networkError: res.networkError };
517
537
  }
518
538
 
539
+ // ── Maintenance sweeps → REST ─────────────────────────────────────────────
540
+ // `POST /memories/purge` and `/purge-expired` (handlers/purge.ts). Both are
541
+ // account-wide and irreversible, and both refuse a token carrying a scope
542
+ // allowlist with a 403 (`refuseAccountWideSweep`). `httpStatus` is passed
543
+ // through so the caller can print that refusal verbatim instead of a generic
544
+ // failure — the real status lives ONLY there, never in `error.code` (see
545
+ // `listScopes` above for why).
546
+
547
+ // `retention_days` is always SENT, never left to the server's default. For an
548
+ // irreversible sweep the request should record the exact window it used, and
549
+ // the caller has to know that number anyway to say what it is about to delete
550
+ // — `lorekit purge`'s confirmation names it. The default itself is derived
551
+ // from the tool catalog (`PURGE_RETENTION_DAYS_DEFAULT`), so sending it
552
+ // explicitly cannot disagree with what the server would have applied.
553
+ //
554
+ // An earlier version omitted the body when the argument was absent, to "let
555
+ // the server default apply". That branch was unreachable — the CLI resolves
556
+ // the default before calling — so it was dead code guarded by a comment that
557
+ // described behaviour the code did not have.
558
+ // Refuses an absent window LOUDLY rather than defaulting to one. `= {}` plus
559
+ // a bare pass-through would send `retention_days: undefined`, which
560
+ // JSON.stringify drops — so a caller that forgot the window would silently
561
+ // get the server's default and hard-delete against a window nobody chose.
562
+ // On an irreversible account-wide sweep that is the worst of the three
563
+ // options; throwing names the mistake at the call site instead. Unreachable
564
+ // today (`parseRetentionDays` resolves and validates before this is called),
565
+ // and cheap insurance for the day a second caller appears.
566
+ async purge({ retentionDays } = {}) {
567
+ if (!Number.isInteger(retentionDays)) {
568
+ throw new Error(
569
+ `purge requires an explicit integer retentionDays (got ${JSON.stringify(retentionDays)}); `
570
+ + 'an account-wide hard delete must never run against an unstated window.',
571
+ );
572
+ }
573
+ const res = await this._rest('/memories/purge', {
574
+ method: 'POST',
575
+ body: { retention_days: retentionDays },
576
+ });
577
+ return {
578
+ ok: res.ok,
579
+ purged: res.ok ? (res.data?.purged ?? 0) : null,
580
+ error: res.error,
581
+ httpStatus: res.httpStatus,
582
+ networkError: res.networkError,
583
+ };
584
+ }
585
+
586
+ // Takes no body — the row set is every TTL-expired memory the caller owns.
587
+ async purgeExpired() {
588
+ const res = await this._rest('/memories/purge-expired', { method: 'POST' });
589
+ return {
590
+ ok: res.ok,
591
+ purged: res.ok ? (res.data?.purged ?? 0) : null,
592
+ error: res.error,
593
+ httpStatus: res.httpStatus,
594
+ networkError: res.networkError,
595
+ };
596
+ }
597
+
519
598
  // ── Org operations → REST ─────────────────────────────────────────────────
520
599
  // `supabase/functions/orgs/` serves `lk_*` tokens on every route as of
521
600
  // 00041_org_actor_override.sql (see the file header). Each method's RETURN