@lorekit/cli 1.46.0 → 1.48.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 +2 -2
- package/bin/lorekit.mjs +9 -4
- package/package.json +1 -1
- package/src/deeplink-pure.mjs +14 -15
- package/src/lessons-view.mjs +1 -1
- package/src/link.mjs +0 -4
- package/src/mcp-server.mjs +186 -3
package/README.md
CHANGED
|
@@ -466,9 +466,9 @@ that lesson's detail sheet. It sets **both** the `lesson` param (which opens the
|
|
|
466
466
|
sheet) and `scope` — not because scope is needed to find the lesson (the sidebar
|
|
467
467
|
reads one unfiltered recent set), but so the Explorer list *behind* the sheet is
|
|
468
468
|
filtered to the lesson's own scope. Filter flags mirror the Explorer: `--q`
|
|
469
|
-
(search), `--owner <all|personal|
|
|
469
|
+
(search), `--owner <all|personal|org-slug>`, `--tags <a,b,c>` (label filter, AND
|
|
470
470
|
across labels; comma-separated or a JSON array), `--range`/`--from`/`--to`,
|
|
471
|
-
`--archived
|
|
471
|
+
`--archived`.
|
|
472
472
|
|
|
473
473
|
Every param is `encodeURIComponent(JSON.stringify(value))` — the exact inverse of
|
|
474
474
|
how the dashboard's `useUrlState` reads it back (`JSON.parse`, falling back to the
|
package/bin/lorekit.mjs
CHANGED
|
@@ -88,7 +88,7 @@ ${c.bold('Commands')}
|
|
|
88
88
|
link (url) Print a shareable dashboard deep-link URL for the current context,
|
|
89
89
|
a scope, or a specific lesson (opens its detail sheet). No args
|
|
90
90
|
links to the cwd's most-specific scope. Filter flags mirror the
|
|
91
|
-
Explorer (--q / --owner / --tags / --range / --archived
|
|
91
|
+
Explorer (--q / --owner / --tags / --range / --archived);
|
|
92
92
|
--base or LOREKIT_APP_URL override the dashboard host. --json. Pipe it:
|
|
93
93
|
lorekit link | pbcopy.
|
|
94
94
|
bootstrap Apply the BYOD schema to a user-supplied Supabase database.
|
|
@@ -585,13 +585,12 @@ ${c.bold('Options')}
|
|
|
585
585
|
--key <key> Name the key explicitly — the way to link to a key
|
|
586
586
|
that itself contains \`::\`
|
|
587
587
|
--q <text> Pre-fill the Explorer search box
|
|
588
|
-
--owner <o> Ownership filter: all | personal | <
|
|
588
|
+
--owner <o> Ownership filter: all | personal | <org-slug>
|
|
589
589
|
--tags <a,b,c> Label filter (AND across labels); comma-separated or a JSON array
|
|
590
590
|
--range <json> Date range as {"from":"YYYY-MM-DD","to":"YYYY-MM-DD"}
|
|
591
591
|
--from <date> Range start (shorthand for --range)
|
|
592
592
|
--to <date> Range end (shorthand for --range)
|
|
593
593
|
--archived Include archived memories
|
|
594
|
-
--view <mode> Explorer view: scope | time
|
|
595
594
|
--base <url> Dashboard base URL (else LOREKIT_APP_URL, default https://lorekit.io)
|
|
596
595
|
--json Machine-readable { url, surface, base, params }
|
|
597
596
|
|
|
@@ -658,7 +657,13 @@ const KNOWN_FLAGS = [
|
|
|
658
657
|
'from', 'to', 'apply', 'yes', 'hooks', 'no-hooks', 'mcp-json', 'force', 'deep', 'adapter',
|
|
659
658
|
'event', 'json', 'scope', 'key', 'threshold', 'help', 'version', 'telemetry',
|
|
660
659
|
'value', 'tags', 'source-agent', 'trigger', 'kind', 'host', 'ttl-days', 'clear-ttl', 'org', 'remote', 'local',
|
|
661
|
-
|
|
660
|
+
// `view` is accepted-and-IGNORED, not documented: the Explorer dropped the
|
|
661
|
+
// scope/time tab so the flag is a no-op, but `link` is a HUMAN_COMMAND that
|
|
662
|
+
// rejects UNKNOWN options — so keeping `view` listed for a release stops an
|
|
663
|
+
// existing `lorekit link --view time` from hard-failing on an unknown-option
|
|
664
|
+
// error. It is parsed and discarded (nothing reads `args.view`). Remove it once
|
|
665
|
+
// 1.x links have aged out.
|
|
666
|
+
'link', 'base', 'q', 'owner', 'range', 'archived', 'view',
|
|
662
667
|
'origin-repo', 'origin-branch', 'origin-commit', 'origin-pr', 'no-origin',
|
|
663
668
|
// Scale-aware survey flags
|
|
664
669
|
'all', 'max', 'since', 'until', 'key-prefix', 'cluster-by-key',
|
package/package.json
CHANGED
package/src/deeplink-pure.mjs
CHANGED
|
@@ -36,7 +36,7 @@ export const LORE_PARAM_DEFAULTS = {
|
|
|
36
36
|
// accepts ISO instants in that arm and a relative { preset: '7d' } arm
|
|
37
37
|
// (packages/web/src/lib/time-range.ts); neither has a CLI flag yet.
|
|
38
38
|
range: null,
|
|
39
|
-
owner: 'all', // 'all' | 'personal' |
|
|
39
|
+
owner: 'all', // 'all' | 'personal' | '<org-slug>' — folds into an owner filter (00064)
|
|
40
40
|
// Filter[] | null — the Explorer's multi-dimension filter bar (label / agent /
|
|
41
41
|
// trigger / repo / branch / pr). `null`, NOT `[]`, is the default on purpose:
|
|
42
42
|
// the app has to tell "the param is absent" from "the bar is explicitly
|
|
@@ -46,7 +46,6 @@ export const LORE_PARAM_DEFAULTS = {
|
|
|
46
46
|
// "unfiltered".
|
|
47
47
|
filters: null,
|
|
48
48
|
tags: [], // string[] — legacy label filter (AND across labels); [] means "no filter". Still READ by the app, superseded by `filters`
|
|
49
|
-
view: 'scope', // 'scope' | 'time'
|
|
50
49
|
// 'active' | 'archived' | 'expiring' | null — the Explorer's Status control.
|
|
51
50
|
// `null`, NOT 'active', is the default for `filters`' reason: the app has to
|
|
52
51
|
// tell "absent" from an explicit choice, because an absent `status` falls back
|
|
@@ -60,9 +59,9 @@ export const LORE_PARAM_DEFAULTS = {
|
|
|
60
59
|
|
|
61
60
|
// A stable, readable param order (also makes URLs deterministic for tests).
|
|
62
61
|
// Mirrors the `useUrlState` call order in `LoreExplorer.tsx` (+ the `lesson`
|
|
63
|
-
// param last), so `filters` and `tags` sit between `owner` and `
|
|
62
|
+
// param last), so `filters` and `tags` sit between `owner` and `status`. `scope`
|
|
64
63
|
// precedes `lesson` so a lesson link reads `?scope=…&lesson=…`.
|
|
65
|
-
const PARAM_ORDER = ['scope', 'q', 'range', 'owner', 'filters', 'tags', '
|
|
64
|
+
const PARAM_ORDER = ['scope', 'q', 'range', 'owner', 'filters', 'tags', 'status', 'archived', 'lesson'];
|
|
66
65
|
|
|
67
66
|
// Strip trailing slashes from a base URL, falling back to the default when the
|
|
68
67
|
// input is empty/absent. Pure.
|
|
@@ -148,18 +147,18 @@ export { resolveScopeArg, resolveScopeKeyArgs, isScopeString, scopeIssue } from
|
|
|
148
147
|
|
|
149
148
|
// ── Flag → param coercion (pure, shared by the `link` command) ────────────────
|
|
150
149
|
|
|
151
|
-
// Coerce the `--owner` flag to
|
|
152
|
-
//
|
|
150
|
+
// Coerce the `--owner` flag to the legacy `owner` param value: `personal`, an
|
|
151
|
+
// org SLUG, or `all` (default → omitted). Returns the STRING the app folds into
|
|
152
|
+
// an owner filter (`filtersFromLegacyOwner`) when the `filters` param is absent
|
|
153
|
+
// — which is why the CLI keeps writing the legacy param rather than `filters`:
|
|
154
|
+
// `owner` and the legacy `tags` param fold together, so `--owner acme --tags
|
|
155
|
+
// perf` yields BOTH, whereas a `filters` param would make the app ignore the
|
|
156
|
+
// legacy tags. NOT the old `{orgId}` OBJECT: the owner facet keys on the SLUG,
|
|
157
|
+
// and the app cannot resolve a uuid to a slug in this pure path, so the object
|
|
158
|
+
// form silently dropped the filter it named (00064). A slug lands verbatim. Pure.
|
|
153
159
|
export function parseOwnerArg(owner) {
|
|
154
|
-
if (typeof owner !== 'string' || !owner
|
|
155
|
-
|
|
156
|
-
return { orgId: owner };
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// Coerce the `--view` flag to a `ViewMode`: only 'time' is non-default; anything
|
|
160
|
-
// else (incl. absent/invalid) → 'scope'. Pure.
|
|
161
|
-
export function parseViewArg(view) {
|
|
162
|
-
return view === 'time' ? 'time' : 'scope';
|
|
160
|
+
if (typeof owner !== 'string' || !owner) return 'all';
|
|
161
|
+
return owner;
|
|
163
162
|
}
|
|
164
163
|
|
|
165
164
|
// Coerce the `--tags` flag to a normalized `string[]` label filter, mirroring the
|
package/src/lessons-view.mjs
CHANGED
|
@@ -40,7 +40,7 @@ export function scopeList({ projectScope, branchScope, repoScope } = {}) {
|
|
|
40
40
|
// deliberately small and in lockstep with that source, including the 64-char
|
|
41
41
|
// host clamp. Lets the offline store, whose rows carry no kind/host column,
|
|
42
42
|
// still be filtered and badged by taxonomy from the tags it does store.
|
|
43
|
-
function inferKindHostFromTags(tags) {
|
|
43
|
+
export function inferKindHostFromTags(tags) {
|
|
44
44
|
if (!Array.isArray(tags)) return {};
|
|
45
45
|
for (const tag of tags) {
|
|
46
46
|
if (tag === 'loop::review-outcomes') return { kind: 'bus', host: 'review' };
|
package/src/link.mjs
CHANGED
|
@@ -22,7 +22,6 @@ import {
|
|
|
22
22
|
buildLoreUrl,
|
|
23
23
|
mostSpecificScope,
|
|
24
24
|
parseOwnerArg,
|
|
25
|
-
parseViewArg,
|
|
26
25
|
parseRangeArg,
|
|
27
26
|
parseTagsArg,
|
|
28
27
|
resolveScopeKeyArgs,
|
|
@@ -55,7 +54,6 @@ export async function link(args) {
|
|
|
55
54
|
// Filter flags (all optional; each JSON-encoded + default-omitted downstream).
|
|
56
55
|
const q = typeof args.q === 'string' ? args.q : '';
|
|
57
56
|
const owner = parseOwnerArg(args.owner);
|
|
58
|
-
const view = parseViewArg(args.view);
|
|
59
57
|
const range = parseRangeArg(args);
|
|
60
58
|
const tags = parseTagsArg(args.tags);
|
|
61
59
|
const archived = Boolean(args.archived);
|
|
@@ -65,7 +63,6 @@ export async function link(args) {
|
|
|
65
63
|
(typeof args.scope === 'string' && Boolean(args.scope)) ||
|
|
66
64
|
Boolean(q) ||
|
|
67
65
|
owner !== 'all' ||
|
|
68
|
-
view !== 'scope' ||
|
|
69
66
|
range !== null ||
|
|
70
67
|
tags.length > 0 ||
|
|
71
68
|
archived;
|
|
@@ -84,7 +81,6 @@ export async function link(args) {
|
|
|
84
81
|
if (q) params.q = q;
|
|
85
82
|
if (owner !== 'all') params.owner = owner;
|
|
86
83
|
if (tags.length) params.tags = tags;
|
|
87
|
-
if (view !== 'scope') params.view = view;
|
|
88
84
|
if (range !== null) params.range = range;
|
|
89
85
|
if (archived) params.archived = true;
|
|
90
86
|
|
package/src/mcp-server.mjs
CHANGED
|
@@ -30,6 +30,7 @@ import { createStore } from './store/index.mjs';
|
|
|
30
30
|
import { createRemoteStore } from './store/remote.mjs';
|
|
31
31
|
import { deriveOrigin, mergeOrigin } from './origin.mjs';
|
|
32
32
|
import { readScopeInventory } from './store/scope-inventory.mjs';
|
|
33
|
+
import { inferKindHostFromTags } from './lessons-view.mjs';
|
|
33
34
|
|
|
34
35
|
const PROTOCOL_VERSION = '2024-11-05';
|
|
35
36
|
const SERVER_INFO = { name: 'lorekit-local', version: '1.0.0' };
|
|
@@ -108,7 +109,10 @@ export const MEMORY_TOOL_DEFS = [
|
|
|
108
109
|
scope: { type: 'string' },
|
|
109
110
|
tags: { type: 'array', items: { type: 'string' } },
|
|
110
111
|
limit: { type: 'integer', minimum: 1, maximum: 100, default: 50 },
|
|
111
|
-
cursor: { type: 'string', description: 'Opaque cursor from a previous response\'s nextCursor. Omit to start from the first page.' },
|
|
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.' },
|
|
112
116
|
},
|
|
113
117
|
},
|
|
114
118
|
},
|
|
@@ -213,6 +217,167 @@ export const ORG_TOOL_DEFS = [
|
|
|
213
217
|
// Legacy alias kept so existing code that imports TOOL_DEFS still compiles.
|
|
214
218
|
export const TOOL_DEFS = [...MEMORY_TOOL_DEFS, ...ORG_TOOL_DEFS];
|
|
215
219
|
|
|
220
|
+
/** Characters of `value` echoed in a `view: "summary"` entry's `preview`. */
|
|
221
|
+
export const LIST_PREVIEW_CHARS = 200;
|
|
222
|
+
|
|
223
|
+
/** The closed `view` vocabulary, mirroring `MemoryListViewSchema`. */
|
|
224
|
+
const LIST_VIEWS = ['full', 'summary'];
|
|
225
|
+
|
|
226
|
+
/** The closed `kind` vocabulary, mirroring `MemoryKindSchema`. */
|
|
227
|
+
const MEMORY_KINDS = ['lesson', 'bus', 'signal'];
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Validate the taxonomy/projection arguments of a `memory.list` call.
|
|
231
|
+
*
|
|
232
|
+
* Every other surface REJECTS an out-of-vocabulary value — the edge throws
|
|
233
|
+
* `UserInputError`, `ListInputSchema` fails the parse. Letting a typo fall
|
|
234
|
+
* through to the default here would make `lorekit mcp` the one path where
|
|
235
|
+
* `view: "sumary"` silently returns full bodies, or `kind: "lessons"` silently
|
|
236
|
+
* returns every bucket. Throwing keeps the contract uniform.
|
|
237
|
+
*/
|
|
238
|
+
function validateListArgs(a = {}) {
|
|
239
|
+
if (a.view !== undefined && !LIST_VIEWS.includes(a.view)) {
|
|
240
|
+
throw new Error(`Invalid view "${a.view}": expected "full" or "summary"`);
|
|
241
|
+
}
|
|
242
|
+
if (a.kind !== undefined && !MEMORY_KINDS.includes(a.kind)) {
|
|
243
|
+
throw new Error(`Invalid kind "${a.kind}": expected "lesson", "bus" or "signal"`);
|
|
244
|
+
}
|
|
245
|
+
if (a.host !== undefined && (typeof a.host !== 'string' || a.host.length === 0 || a.host.length > 64)) {
|
|
246
|
+
throw new Error('Invalid host: expected a non-empty string of at most 64 characters');
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Post-filter a list result by `kind` / `host`.
|
|
252
|
+
*
|
|
253
|
+
* The remote store forwards both to `GET /memories` and they are narrowed
|
|
254
|
+
* server-side; the LOCAL store has no kind/host columns and ignores them
|
|
255
|
+
* entirely, so without this a local call that asked to narrow would get the
|
|
256
|
+
* whole scope back and look filtered. This is the same post-filter the read
|
|
257
|
+
* commands already apply in `gather()`, for the same reason — and it is
|
|
258
|
+
* idempotent over already-narrowed remote rows.
|
|
259
|
+
*
|
|
260
|
+
* Taxonomy is taken from the stored columns when present, else inferred from
|
|
261
|
+
* the `loop::…` tag, so an offline row and a pre-`00056` remote row both filter
|
|
262
|
+
* the way a caller expects.
|
|
263
|
+
*/
|
|
264
|
+
function filterListTaxonomy(result, { kind, host } = {}) {
|
|
265
|
+
if ((!kind && !host) || !result?.ok || !Array.isArray(result.entries)) return result;
|
|
266
|
+
const entries = result.entries.filter((e) => {
|
|
267
|
+
const inferred = inferKindHostFromTags(e?.tags);
|
|
268
|
+
const k = e?.kind ?? inferred.kind ?? null;
|
|
269
|
+
const h = e?.host ?? inferred.host ?? null;
|
|
270
|
+
return (!kind || k === kind) && (!host || h === host);
|
|
271
|
+
});
|
|
272
|
+
return { ...result, entries };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Apply the `view` projection to a store's list result.
|
|
277
|
+
*
|
|
278
|
+
* `full` (and an absent value) passes the result through untouched. `summary`
|
|
279
|
+
* swaps each entry's `value` for its byte size and a bounded prefix, so a
|
|
280
|
+
* discovery read costs an index instead of every body. An out-of-vocabulary
|
|
281
|
+
* value never reaches here — `validateListArgs` rejects it first.
|
|
282
|
+
*
|
|
283
|
+
* The slice is over `[...value]`, NOT `value.slice()`: JS string indices are
|
|
284
|
+
* UTF-16 code units, so a naive cut can land between a surrogate pair and emit
|
|
285
|
+
* a lone half. Spreading iterates code points, so an emoji or CJK character is
|
|
286
|
+
* never split. `value_bytes` is the UTF-8 byte length so it stays comparable
|
|
287
|
+
* with the 65,536-byte value cap.
|
|
288
|
+
*/
|
|
289
|
+
export function projectListView(result, view) {
|
|
290
|
+
if (view !== 'summary' || !result?.ok || !Array.isArray(result.entries)) return result;
|
|
291
|
+
return {
|
|
292
|
+
...result,
|
|
293
|
+
entries: result.entries.map(({ value, ...rest }) => ({
|
|
294
|
+
...rest,
|
|
295
|
+
value_bytes: Buffer.byteLength(value ?? '', 'utf8'),
|
|
296
|
+
preview: [...(value ?? '')].slice(0, LIST_PREVIEW_CHARS).join(''),
|
|
297
|
+
})),
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* How many rows to ask the store for when a taxonomy filter is active.
|
|
303
|
+
*
|
|
304
|
+
* Both stores apply `limit` BEFORE this module can post-filter — `LocalStore`
|
|
305
|
+
* and `TwoTierStore` slice in `list()`, and the remote route pages server-side.
|
|
306
|
+
* Without an over-fetch, `{ limit: 5, host: 'reviewer' }` over a scope holding
|
|
307
|
+
* 5 `aw` rows then 5 `reviewer` rows asks for 5, gets the 5 `aw` rows, filters
|
|
308
|
+
* them all away, and answers with zero entries — a silently empty read that
|
|
309
|
+
* looks like "no reviewer lessons exist".
|
|
310
|
+
*
|
|
311
|
+
* Over-fetching cannot be exact — only the server knows the true distribution —
|
|
312
|
+
* so the widened fetch is simply the largest page the backend will serve, and
|
|
313
|
+
* when it still comes back saturated the result carries `hasMore: true` so the
|
|
314
|
+
* caller knows the page was cut rather than exhausted.
|
|
315
|
+
*
|
|
316
|
+
* That maximum is **100**, and it is the route's constraint rather than a
|
|
317
|
+
* tuning choice: `ListMemoriesQuerySchema` caps `GET /memories`'s `limit` at
|
|
318
|
+
* 100, so asking for more is a 400 from the remote store — which would break
|
|
319
|
+
* `kind`/`host` for every request above `limit: 10` rather than merely
|
|
320
|
+
* under-filling it. Since the floor a scaled over-fetch would want is already
|
|
321
|
+
* at or above that cap for every supported `limit`, there is nothing to scale:
|
|
322
|
+
* one constant is the honest expression of the rule.
|
|
323
|
+
*/
|
|
324
|
+
const TAXONOMY_FETCH_LIMIT = 100;
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* The full `memory.list` post-processing chain: validate → fetch → filter →
|
|
328
|
+
* slice → project.
|
|
329
|
+
*
|
|
330
|
+
* The slice happens HERE rather than in the store whenever a taxonomy filter is
|
|
331
|
+
* active, because the store cannot honour both `limit` and a filter it does not
|
|
332
|
+
* implement. See `TAXONOMY_FETCH_LIMIT` for why the fetch is widened.
|
|
333
|
+
*/
|
|
334
|
+
export async function listWithFilters(store, a = {}) {
|
|
335
|
+
validateListArgs(a);
|
|
336
|
+
const filtering = Boolean(a.kind || a.host);
|
|
337
|
+
if (!filtering) return projectListView(await store.list(a), a.view);
|
|
338
|
+
|
|
339
|
+
const requested = a.limit ?? 50;
|
|
340
|
+
const widened = TAXONOMY_FETCH_LIMIT;
|
|
341
|
+
// Drop `cursor` as well as widening `limit`. A cursor is a keyset position in
|
|
342
|
+
// the UNFILTERED row order; resuming a client-side-filtered read from one
|
|
343
|
+
// would start mid-way through a sequence this call never produced. The tool
|
|
344
|
+
// schema says `cursor` is ignored when `kind`/`host` is set, and this is what
|
|
345
|
+
// makes that true rather than merely aspirational.
|
|
346
|
+
const { cursor: _ignoredCursor, ...rest } = a;
|
|
347
|
+
const raw = await store.list({ ...rest, limit: widened });
|
|
348
|
+
const filtered = filterListTaxonomy(raw, a);
|
|
349
|
+
if (!filtered?.ok || !Array.isArray(filtered.entries)) return projectListView(filtered, a.view);
|
|
350
|
+
|
|
351
|
+
const page = filtered.entries.slice(0, requested);
|
|
352
|
+
// `hasMore` is true when this page was cut — either by our own slice, or
|
|
353
|
+
// because the widened fetch itself saturated and rows beyond it were never
|
|
354
|
+
// examined. Preserve an upstream `hasMore` too; the remote store sets it.
|
|
355
|
+
const truncated =
|
|
356
|
+
filtered.entries.length > requested ||
|
|
357
|
+
(Array.isArray(raw?.entries) && raw.entries.length >= widened);
|
|
358
|
+
|
|
359
|
+
// `nextCursor` MUST be null on a taxonomy-filtered read, never the upstream
|
|
360
|
+
// cursor. That cursor is a keyset position in the UNFILTERED row order, taken
|
|
361
|
+
// from the end of the WIDENED fetch — so handing it back after returning only
|
|
362
|
+
// `requested` post-filter rows would make the next page resume past every row
|
|
363
|
+
// between the slice and the widened window, silently skipping matches.
|
|
364
|
+
//
|
|
365
|
+
// There is no correct cursor to synthesise here: the filter is applied client
|
|
366
|
+
// side, so no server-side keyset describes "the next filtered row". A filtered
|
|
367
|
+
// list is therefore a single bounded page, exactly as `order: "rank"` is on
|
|
368
|
+
// the edge — `hasMore` reports that it was cut, and the remedy is a larger
|
|
369
|
+
// `limit`, not pagination.
|
|
370
|
+
return projectListView(
|
|
371
|
+
{
|
|
372
|
+
...filtered,
|
|
373
|
+
entries: page,
|
|
374
|
+
hasMore: Boolean(raw?.hasMore) || truncated,
|
|
375
|
+
nextCursor: null,
|
|
376
|
+
},
|
|
377
|
+
a.view,
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
|
|
216
381
|
// tool name → (store, args, ctx) → store result. The store destructures the
|
|
217
382
|
// args it needs, so the raw `arguments` object is passed straight through.
|
|
218
383
|
// `ctx.root` is the resolved project root (`--dir`), NOT the process cwd — an
|
|
@@ -223,7 +388,13 @@ const MEMORY_DISPATCH = {
|
|
|
223
388
|
// with anything the caller DID pass taking precedence.
|
|
224
389
|
'memory.write': (store, a, ctx) => store.write({ ...a, ...withDerivedOrigin(a, ctx) }),
|
|
225
390
|
'memory.read': (store, a) => store.read(a),
|
|
226
|
-
|
|
391
|
+
// `view` is projected and `kind`/`host` post-filtered client-side rather than
|
|
392
|
+
// forwarded. The remote store reads `GET /memories`, which has no `view`
|
|
393
|
+
// parameter — only the MCP tool does — and the local store has neither the
|
|
394
|
+
// parameter nor the columns. Doing the work here keeps the stdio server's
|
|
395
|
+
// contract identical to the hosted one on both store backends, which is the
|
|
396
|
+
// whole point of `MEMORY_TOOL_DEFS` mirroring the catalog.
|
|
397
|
+
'memory.list': (store, a) => listWithFilters(store, a),
|
|
227
398
|
'memory.search': (store, a) => store.search(a),
|
|
228
399
|
'memory.delete': (store, a) => store.delete(a),
|
|
229
400
|
'memory.archive': (store, a) => store.archive(a),
|
|
@@ -411,7 +582,19 @@ export function createHandler(control, { root = process.cwd() } = {}) {
|
|
|
411
582
|
const fn = MEMORY_DISPATCH[name];
|
|
412
583
|
if (!fn) return errorReply(id, -32601, `Unknown tool: ${name}`);
|
|
413
584
|
|
|
414
|
-
|
|
585
|
+
// A rejected ARGUMENT is a tool-level failure, not a broken transport.
|
|
586
|
+
// Letting the throw escape would answer JSON-RPC -32603 "Internal error",
|
|
587
|
+
// which tells the model nothing and contradicts what `toolResult`
|
|
588
|
+
// documents; the edge returns a `UserInputError` payload for the same
|
|
589
|
+
// typo. Surface it as `{ ok: false, error }` so the model can correct
|
|
590
|
+
// itself. Only argument validation is caught here — a store failure
|
|
591
|
+
// already comes back as `ok: false` rather than throwing.
|
|
592
|
+
let result;
|
|
593
|
+
try {
|
|
594
|
+
result = await fn(store, args, { root });
|
|
595
|
+
} catch (e) {
|
|
596
|
+
return toolResult(id, { ok: false, error: (e && e.message) || 'invalid arguments' });
|
|
597
|
+
}
|
|
415
598
|
return toolResult(id, result);
|
|
416
599
|
}
|
|
417
600
|
|