@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.
- package/README.md +49 -4
- package/bin/lorekit.mjs +200 -92
- package/package.json +1 -1
- package/src/commands.mjs +112 -0
- package/src/control.mjs +15 -5
- package/src/doctor.mjs +18 -0
- package/src/mcp-server.mjs +68 -181
- package/src/mcp.mjs +25 -1
- package/src/purge.mjs +191 -0
- package/src/store/remote.mjs +80 -1
- package/src/surfaces.generated.mjs +516 -0
- package/src/telemetry-identity.mjs +276 -0
- package/src/telemetry.mjs +167 -10
package/src/mcp-server.mjs
CHANGED
|
@@ -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
|
|
39
|
-
//
|
|
40
|
-
//
|
|
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'); }
|
package/src/store/remote.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|