From 47292e08f1d90157a08bb417c9a2b442b58cd6be Mon Sep 17 00:00:00 2001 From: "mads.thines" Date: Tue, 20 Oct 2026 13:01:10 +0000 Subject: [PATCH 1/9] feat: filter by tags in the Explorer menu and in lorekit list/search - Explorer: show the label dimension as "Tag" (field id unchanged so ?filters= links keep working) and match "tags"/"label" when typed - CLI: add --tags/--tags-mode to list and search; previously --tags was accepted (known for write) and silently ignored - local store honours tags_mode; remote store forwards it Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com> --- packages/cli/bin/lorekit.mjs | 7 + packages/cli/src/commands/list.mjs | 27 ++-- packages/cli/src/commands/search.mjs | 17 ++- packages/cli/src/shared/completions.mjs | 5 +- packages/cli/src/shared/flags.mjs | 33 +++++ packages/cli/src/shared/lessons-pure.mjs | 22 ++++ packages/cli/src/shared/lessons-view.mjs | 61 +++++++-- packages/cli/src/store/local.mjs | 14 +- packages/cli/src/store/remote.mjs | 8 +- packages/cli/test/list.test.mjs | 120 +++++++++++++++++- packages/cli/test/search.test.mjs | 24 ++++ .../lore/FilterMenu.test.stories.tsx | 34 ++--- .../web/src/components/lore/FilterMenu.tsx | 2 +- packages/web/src/content/docs/tags.mdx | 2 +- packages/web/src/lib/filters.spec.ts | 18 ++- packages/web/src/lib/filters.ts | 25 +++- packages/web/src/lib/tag-filter.ts | 6 +- 17 files changed, 355 insertions(+), 70 deletions(-) diff --git a/packages/cli/bin/lorekit.mjs b/packages/cli/bin/lorekit.mjs index 7107227e0..8fac286b9 100755 --- a/packages/cli/bin/lorekit.mjs +++ b/packages/cli/bin/lorekit.mjs @@ -350,6 +350,8 @@ short note on how to set it up — never an error. ${c.bold('Options')} -d, --dir Target project root (default: current directory) --scope Restrict to a single scope (default: all applicable) + --tags Only memories carrying these tags (combined by --tags-mode) + --tags-mode any (at least one, default) | all (every one) | none --json Machine-readable output -e, --endpoint Remote endpoint override (else .mcp.json / LOREKIT_MCP_URL) -t, --token Remote token override (else .mcp.json / LOREKIT_TOKEN) @@ -360,6 +362,8 @@ ${c.bold('Examples')} npx @lorekit/cli list npx @lorekit/cli list --json npx @lorekit/cli list --scope global + npx @lorekit/cli list --tags loop::aw-lessons + npx @lorekit/cli list --tags perf,ci --tags-mode all npx @lorekit/cli list --scope global --link `, search: `${c.bold('lorekit search')} — full-text search the applicable memories ${c.dim('(alias: grep)')} @@ -376,6 +380,8 @@ never interpreted). Results are shown in the same Offline / Remote split as ${c.bold('Options')} -d, --dir Target project root (default: current directory) --scope Restrict to a single scope (default: all applicable) + --tags Only search memories carrying these tags (combined by --tags-mode) + --tags-mode any (at least one, default) | all (every one) | none --json Machine-readable output -e, --endpoint Remote endpoint override (else .mcp.json / LOREKIT_MCP_URL) -t, --token Remote token override (else .mcp.json / LOREKIT_TOKEN) @@ -386,6 +392,7 @@ ${c.bold('Examples')} npx @lorekit/cli search sandbox npx @lorekit/cli grep "flaky test" --json npx @lorekit/cli search migration --scope global + npx @lorekit/cli search migration --tags loop::aw-lessons npx @lorekit/cli search "flaky test" --scope global --link `, write: `${c.bold('lorekit write')} — create or update a memory from the CLI diff --git a/packages/cli/src/commands/list.mjs b/packages/cli/src/commands/list.mjs index 20d8d8117..778bd8d81 100644 --- a/packages/cli/src/commands/list.mjs +++ b/packages/cli/src/commands/list.mjs @@ -13,10 +13,11 @@ import { resolveProjectRoot } from '../shared/config.mjs'; import { deriveScope } from '../shared/scope.mjs'; import { resolveDenies } from '../shared/control.mjs'; import { resolveStores, remoteUnavailableReason } from '../shared/stores.mjs'; -import { scopeList, gather, gatherStream, renderSection, DEFAULT_MAX } from '../shared/lessons-view.mjs'; +import { scopeList, gather, gatherStream, renderSection, entryFilter, DEFAULT_MAX } from '../shared/lessons-view.mjs'; import { resolveAppBase, mostSpecificScope } from '../shared/deeplink-pure.mjs'; +import { parseTagsFilter } from '../shared/flags.mjs'; import { emitLink } from './link.mjs'; -import { log, heading, c } from '../shared/util.mjs'; +import { log, err, heading, c } from '../shared/util.mjs'; // Abbreviate the user's home directory to `~` for readable paths. function prettyPath(p) { @@ -46,6 +47,13 @@ export async function list(args) { const filters = {}; if (typeof args.kind === 'string') filters.kind = args.kind; if (typeof args.host === 'string') filters.host = args.host; + // `--tags a,b [--tags-mode any|all|none]` — same semantics as `memory.list`. + const tagFilter = parseTagsFilter(args); + if (tagFilter.error) { + err(`${c.red('Error:')} ${tagFilter.error}`); + return 1; + } + Object.assign(filters, tagFilter.filters); // `--link` short-circuits: print the Explorer deep link for the current // context (the most-specific applicable scope, or `--scope`), no store reads. @@ -77,16 +85,10 @@ export async function list(args) { const surveySince = args.since || undefined; const surveyUntil = args.until || undefined; - // Taxonomy `keep` predicate mirroring `gather()`'s: `gatherStream` neither - // forwards `kind`/`host` to the store nor post-filters, so a `--all` drain - // must apply the same narrowing here or `list --all --kind X` would ignore X. - const wanted = (v) => - v == null ? null : new Set(String(v).split(',').map((s) => s.trim()).filter(Boolean)); - const kindSet = wanted(filters.kind); - const hostSet = wanted(filters.host); - const keep = (e) => - (!kindSet || (e.kind != null && kindSet.has(e.kind))) && - (!hostSet || (e.host != null && hostSet.has(e.host))); + // The SAME `keep` predicate `gather()` applies: `gatherStream` neither + // forwards the filters to the store nor post-filters, so a `--all` drain must + // apply the narrowing here or `list --all --tags X` would ignore X. + const keep = entryFilter(filters); const offline = localDenied ? { groups: [], total: 0 } : await gather(local, scopes, filters); const remoteAvailable = !remoteDenied && remote.usable(); @@ -157,6 +159,7 @@ export async function list(args) { 'lorekit.cli.list.offline_count': offline.total, 'lorekit.cli.list.remote_count': remoteResult.total, 'lorekit.cli.list.remote_available': remoteAvailable, + 'lorekit.cli.list.tags_filtered': Boolean(filters.tags), }; } diff --git a/packages/cli/src/commands/search.mjs b/packages/cli/src/commands/search.mjs index 0bd791fa7..9ebab0ae0 100644 --- a/packages/cli/src/commands/search.mjs +++ b/packages/cli/src/commands/search.mjs @@ -20,6 +20,7 @@ import { resolveDenies } from '../shared/control.mjs'; import { resolveStores, remoteUnavailableReason } from '../shared/stores.mjs'; import { scopeList, gather, filterGroups, renderSection } from '../shared/lessons-view.mjs'; import { resolveAppBase, mostSpecificScope } from '../shared/deeplink-pure.mjs'; +import { parseTagsFilter } from '../shared/flags.mjs'; import { emitLink } from './link.mjs'; import { log, err, heading, c } from '../shared/util.mjs'; @@ -33,7 +34,7 @@ export async function search(args) { // and would otherwise degrade to `list`. const query = typeof args._[1] === 'string' ? args._[1] : ''; if (!query.trim()) { - err(`${c.red('Usage:')} lorekit search [--scope ] [--json]`); + err(`${c.red('Usage:')} lorekit search [--scope ] [--tags [--tags-mode any|all|none]] [--json]`); err(`Provide a search term. Run ${c.cyan('lorekit search --help')} for options.`); return 1; } @@ -43,6 +44,15 @@ export async function search(args) { // scope outside the applicable set is honoured — the user asked for it). const scopes = args.scope && typeof args.scope === 'string' ? [args.scope] : scopeList(scopeInfo); + // `--tags a,b [--tags-mode any|all|none]` narrows the candidate set before the + // query is matched — the same filter, and the same semantics, as `list`. + const tagFilter = parseTagsFilter(args); + if (tagFilter.error) { + err(`${c.red('Error:')} ${tagFilter.error}`); + return 1; + } + const filters = tagFilter.filters; + // `--link` short-circuits: print the Explorer deep link for this search // (`?q=…` plus the most-specific applicable scope), without touching a store. if (args.link) { @@ -65,10 +75,10 @@ export async function search(args) { const offline = localDenied ? { groups: [], total: 0 } - : filterGroups(await gather(local, scopes), query); + : filterGroups(await gather(local, scopes, filters), query); const remoteAvailable = !remoteDenied && remote.usable(); const remoteResult = remoteAvailable - ? filterGroups(await gather(remote, scopes), query) + ? filterGroups(await gather(remote, scopes, filters), query) : { groups: [], total: 0 }; const offlineSection = localDenied @@ -108,6 +118,7 @@ export async function search(args) { 'lorekit.cli.search.offline_matches': offline.total, 'lorekit.cli.search.remote_matches': remoteResult.total, 'lorekit.cli.search.remote_available': remoteAvailable, + 'lorekit.cli.search.tags_filtered': Boolean(filters.tags), }; } diff --git a/packages/cli/src/shared/completions.mjs b/packages/cli/src/shared/completions.mjs index bdb816cd2..8c2f25f2c 100644 --- a/packages/cli/src/shared/completions.mjs +++ b/packages/cli/src/shared/completions.mjs @@ -51,6 +51,7 @@ const FLAG = { 'cluster-by-key': { desc: 'Cluster by shared key capture', arg: 'regex' }, value: { desc: 'Memory value', arg: 'text' }, tags: { desc: 'Comma-separated tags', arg: 'a,b,c' }, + 'tags-mode': { desc: 'How --tags combine', arg: 'mode', values: ['any', 'all', 'none'] }, 'source-agent': { desc: 'Source agent name to record', arg: 'name' }, trigger: { desc: 'Trigger context slug', arg: 'slug' }, 'ttl-days': { desc: 'Days until auto-expiry (1..365)', arg: 'n' }, @@ -111,10 +112,10 @@ const COMMANDS = [ { name: 'update', summary: 'Offline refresh of the bundled skills to the shipped version', flags: ['dir', 'project', 'global', 'check'] }, { name: 'list', summary: 'List memories for the current directory', aliases: ['ls'], - flags: ['dir', 'scope', 'json', 'endpoint', 'token', 'store', 'link', 'base'] }, + flags: ['dir', 'scope', 'tags', 'tags-mode', 'json', 'endpoint', 'token', 'store', 'link', 'base'] }, { name: 'search', summary: 'Full-text search the applicable memories', aliases: ['grep'], positional: 'query', - flags: ['dir', 'scope', 'json', 'endpoint', 'token', 'store', 'link', 'base'] }, + flags: ['dir', 'scope', 'tags', 'tags-mode', 'json', 'endpoint', 'token', 'store', 'link', 'base'] }, { name: 'show', summary: 'Inspect one memory in full', positional: 'address', flags: ['dir', 'json', 'endpoint', 'token', 'store', 'scope', 'key', 'link', 'base'] }, { name: 'stats', summary: 'Count memories per scope and store', diff --git a/packages/cli/src/shared/flags.mjs b/packages/cli/src/shared/flags.mjs index 2c47f91d6..db254c8f3 100644 --- a/packages/cli/src/shared/flags.mjs +++ b/packages/cli/src/shared/flags.mjs @@ -87,3 +87,36 @@ export function parseDimensionConditions(args, { clearable = false } = {}) { } return { conditions }; } + +/** + * Parse the read commands' tag filter — `--tags a,b [--tags-mode any|all|none]` + * on `list` and `search`. + * + * Returns `{ filters: {} }` when `--tags` was not given, `{ filters: { tags, + * tags_mode } }` when it was (the mode defaulting to `any`, the hosted list's + * own default, so offline and remote agree), or `{ error }` naming the flag. + * + * Stricter than {@link parseDimensionConditions} on purpose: a bare `--tags` + * with no value, or a `--tags-mode` with no `--tags`, is rejected rather than + * quietly listing everything — that silent no-op is the bug this flag exists + * to fix (`tags` was already a known flag for `write`, so `list --tags x` used + * to parse cleanly and filter nothing). + */ +export function parseTagsFilter(args) { + const { modes } = DIMENSIONS.find((d) => d.flag === 'tags'); + const raw = args.tags; + const modeRaw = args['tags-mode']; + + if (modeRaw !== undefined && !modes.includes(modeRaw)) { + return { error: `--tags-mode must be one of ${modes.join('|')}, got ${JSON.stringify(String(modeRaw))}` }; + } + if (raw === undefined) { + return modeRaw === undefined ? { filters: {} } : { error: '--tags-mode needs --tags ' }; + } + + const tags = typeof raw === 'string' + ? [...new Set(raw.split(',').map((s) => s.trim()).filter((s) => s.length > 0))] + : []; + if (tags.length === 0) return { error: '--tags needs a comma-separated list, e.g. --tags loop::aw-lessons,perf' }; + return { filters: { tags, tags_mode: modeRaw ?? 'any' } }; +} diff --git a/packages/cli/src/shared/lessons-pure.mjs b/packages/cli/src/shared/lessons-pure.mjs index 04a39ba10..07114d52a 100644 --- a/packages/cli/src/shared/lessons-pure.mjs +++ b/packages/cli/src/shared/lessons-pure.mjs @@ -64,6 +64,28 @@ export function matchesQuery(entry, query) { return key.includes(needle) || value.includes(needle); } +// ── tag matching (the `--tags` filter core, shared with the local store) ────── + +// The `tags_mode` values the hosted `GET /memories` accepts, and the default it +// applies. `list`/`search` adopt the same default so `--tags` means the same +// thing offline and online. +export const TAGS_MODES = Object.freeze(['any', 'all', 'none']); +export const DEFAULT_TAGS_MODE = 'any'; + +// Whether an entry's tags satisfy `wanted` (an iterable of tags) under `mode` — +// the hosted list's semantics: `any` = carries at least one, `all` = carries +// every one, `none` = carries none of them. An empty `wanted` matches +// everything. Pure. +export function matchesTags(entryTags, wanted, mode = DEFAULT_TAGS_MODE) { + const want = [...new Set(wanted ?? [])]; + if (want.length === 0) return true; + const have = new Set(Array.isArray(entryTags) ? entryTags : []); + const hits = want.filter((t) => have.has(t)).length; + if (mode === 'all') return hits === want.length; + if (mode === 'none') return hits === 0; + return hits > 0; +} + // ── the scope grammar + the scope/key argument parser ───────────────────────── // // These three live together, in the dependency-free module, because they are ONE diff --git a/packages/cli/src/shared/lessons-view.mjs b/packages/cli/src/shared/lessons-view.mjs index 0aa2c0e9f..585ae6e5e 100644 --- a/packages/cli/src/shared/lessons-view.mjs +++ b/packages/cli/src/shared/lessons-view.mjs @@ -17,15 +17,30 @@ import { log, heading, status, c } from './util.mjs'; // parsers live there too — the `lint` rule below and the `write`/`show`/`link` // argument handling must agree on ONE scope grammar, and the parser is only // decidable because it can ask the validator. +// `matchesTags` (the `--tags` matcher) lives there for the same reason: the +// local store applies it too, and must not import this file's render stack. import { resolvePrecedence, matchesQuery, + matchesTags, + TAGS_MODES, + DEFAULT_TAGS_MODE, scopeIssue, isScopeString, resolveScopeArg, resolveScopeKeyArgs, } from './lessons-pure.mjs'; -export { resolvePrecedence, matchesQuery, scopeIssue, isScopeString, resolveScopeArg, resolveScopeKeyArgs }; +export { + resolvePrecedence, + matchesQuery, + matchesTags, + TAGS_MODES, + DEFAULT_TAGS_MODE, + scopeIssue, + isScopeString, + resolveScopeArg, + resolveScopeKeyArgs, +}; // The scopes that apply to the current directory, most-specific → broadest: // project, branch, repo, global. De-duplicated (a repo with no branch scope, @@ -576,6 +591,32 @@ export function clusterByKeyPattern(entries = [], pattern) { return clusters; } +// A comma list (or array) as a de-duplicated set of trimmed values, or `null` +// when the filter was not given at all. +function valueSet(v) { + if (v == null) return null; + const list = Array.isArray(v) ? v : String(v).split(','); + const set = new Set(list.map((s) => String(s).trim()).filter(Boolean)); + return set.size > 0 ? set : null; +} + +// The `keep` predicate for normalized entries under the read commands' filters +// (`kind`, `host`, `tags` + `tags_mode`). ONE predicate for every path that +// narrows a listing — `gather()`, and `list --all`'s streamed drain — so the +// offline store, the remote store and a full drain can never disagree about +// what a filter means. Kind/host match the taxonomy `normalizeEntry` inferred +// for a row without explicit columns. Pure. +export function entryFilter(filters = {}) { + const kindSet = valueSet(filters.kind); + const hostSet = valueSet(filters.host); + const tagSet = valueSet(filters.tags); + const tagsMode = filters.tags_mode ?? DEFAULT_TAGS_MODE; + return (e) => + (!kindSet || (e.kind != null && kindSet.has(e.kind))) && + (!hostSet || (e.host != null && hostSet.has(e.host))) && + matchesTags(e.tags, tagSet, tagsMode); +} + // Collect a store's non-archived entries for each scope, via the common // `store.list({scope})` contract. Returns ordered per-scope groups plus a total // — a per-scope read failure is captured on the group, never thrown, so one bad @@ -583,19 +624,11 @@ export function clusterByKeyPattern(entries = [], pattern) { // Single-page only (the existing default behaviour). For full-scope traversal // use `gatherStream` below. export async function gather(store, scopes, filters = {}) { - // Parse the taxonomy filters into value sets. `filters` is passed to the store - // too (the remote narrows server-side); we ALSO post-filter the normalized - // entries here so the offline store — which ignores kind/host in its own - // `list()` — stays consistent with remote. Post-filtering the remote rows is - // idempotent (they were already narrowed) and matches on the same inferred - // taxonomy a row without explicit columns gets in normalizeEntry. - const wanted = (v) => - v == null ? null : new Set(String(v).split(',').map((s) => s.trim()).filter(Boolean)); - const kindSet = wanted(filters.kind); - const hostSet = wanted(filters.host); - const keep = (e) => - (!kindSet || (e.kind != null && kindSet.has(e.kind))) && - (!hostSet || (e.host != null && hostSet.has(e.host))); + // `filters` is passed to the store too (the remote narrows server-side); we + // ALSO post-filter the normalized entries here so the offline store — which + // ignores kind/host in its own `list()` — stays consistent with remote. + // Post-filtering the remote rows is idempotent (they were already narrowed). + const keep = entryFilter(filters); const groups = []; let total = 0; diff --git a/packages/cli/src/store/local.mjs b/packages/cli/src/store/local.mjs index f4e2f1edb..ada9ba925 100644 --- a/packages/cli/src/store/local.mjs +++ b/packages/cli/src/store/local.mjs @@ -12,6 +12,7 @@ import { normalizeCreatedAt } from './created-at.mjs'; import { isLive, resolveExpiresAt } from './ttl.mjs'; import { seenCountOf, withReadFields, updatedAtOf } from './entry-fields.mjs'; import { pickScopeWinner } from '../shared/scope-precedence.mjs'; +import { matchesTags } from '../shared/lessons-pure.mjs'; export function createLocalStore(baseDir) { return new LocalStore(baseDir); @@ -79,13 +80,16 @@ class LocalStore { // list({ scope, tags, limit }) → { ok, entries } — newest-first, tag-filtered, // archived hidden, expired hidden (lazily, mirroring the remote read paths). - async list({ scope, tags, limit } = {}) { + // `tags_mode` defaults to `all` here — what this store has always done with + // `tags`, and what `search()` below relies on. The read commands pass the + // mode explicitly (`any` unless told otherwise), matching the hosted list. + async list({ scope, tags, tags_mode = 'all', limit } = {}) { const now = new Date(); let rows = this._readAll(scope) .map((r) => r.entry) .filter((e) => isLive(e, now)); if (Array.isArray(tags) && tags.length) { - rows = rows.filter((e) => tags.every((t) => (e.tags || []).includes(t))); + rows = rows.filter((e) => matchesTags(e.tags, tags, tags_mode)); } rows.sort((a, b) => String(b.updated || '').localeCompare(String(a.updated || ''))); if (limit) rows = rows.slice(0, limit); @@ -446,10 +450,10 @@ class TwoTierStore { return this.projectActive() ? this.project : this.home; } - async list({ scope, tags, limit } = {}) { - const homeRes = await this.home.list({ scope, tags }); + async list({ scope, tags, tags_mode, limit } = {}) { + const homeRes = await this.home.list({ scope, tags, tags_mode }); const projRes = this.projectActive() - ? await this.project.list({ scope, tags }) + ? await this.project.list({ scope, tags, tags_mode }) : { entries: [] }; const merged = mergeByKey(projRes.entries, homeRes.entries, (e) => e.key); merged.sort((a, b) => String(b.updated || '').localeCompare(String(a.updated || ''))); diff --git a/packages/cli/src/store/remote.mjs b/packages/cli/src/store/remote.mjs index c98dc2e92..6be2e605d 100644 --- a/packages/cli/src/store/remote.mjs +++ b/packages/cli/src/store/remote.mjs @@ -189,10 +189,14 @@ class RemoteStore { // ── Memory operations → REST ────────────────────────────────────────────── - async list({ scope, tags, kind, host, limit, cursor, created_since, created_until, key_prefix } = {}) { + async list({ scope, tags, tags_mode, kind, host, limit, cursor, created_since, created_until, key_prefix } = {}) { const p = new URLSearchParams(); if (scope) p.set('scope', scope); - if (tags?.length) p.set('tags', Array.isArray(tags) ? tags.join(',') : tags); + if (tags?.length) { + p.set('tags', Array.isArray(tags) ? tags.join(',') : tags); + // Omitted → the route's own default (`any`). + if (tags_mode) p.set('tags_mode', tags_mode); + } if (kind) p.set('kind', Array.isArray(kind) ? kind.join(',') : kind); if (host) p.set('host', Array.isArray(host) ? host.join(',') : host); if (limit) p.set('limit', String(limit)); diff --git a/packages/cli/test/list.test.mjs b/packages/cli/test/list.test.mjs index a7651f754..b9ff8a6bc 100644 --- a/packages/cli/test/list.test.mjs +++ b/packages/cli/test/list.test.mjs @@ -15,9 +15,11 @@ import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { scopeList, normalizeEntry, preview, shortDate, gather } from '../src/shared/lessons-view.mjs'; +import { scopeList, normalizeEntry, preview, shortDate, gather, matchesTags, entryFilter } from '../src/shared/lessons-view.mjs'; import { remoteUnavailableReason } from '../src/shared/stores.mjs'; +import { parseTagsFilter } from '../src/shared/flags.mjs'; import { createLocalStore } from '../src/store/local.mjs'; +import { createRemoteStore } from '../src/store/remote.mjs'; const BIN = fileURLToPath(new URL('../bin/lorekit.mjs', import.meta.url)); const tmp = (prefix) => fs.mkdtempSync(path.join(os.tmpdir(), prefix)); @@ -111,6 +113,86 @@ test('gather captures a per-scope read failure instead of throwing', async () => assert.equal(groups[0].error, 'fetch failed'); }); +// ── unit: the --tags filter ─────────────────────────────────────────────────── + +test('matchesTags applies the hosted any/all/none semantics', () => { + const tags = ['perf', 'ci']; + assert.equal(matchesTags(tags, ['perf', 'auth'], 'any'), true); + assert.equal(matchesTags(tags, ['auth'], 'any'), false); + assert.equal(matchesTags(tags, ['perf', 'ci'], 'all'), true); + assert.equal(matchesTags(tags, ['perf', 'auth'], 'all'), false); + assert.equal(matchesTags(tags, ['auth'], 'none'), true); + assert.equal(matchesTags(tags, ['perf'], 'none'), false); + // `any` is the default, an empty wanted list matches everything, and an + // untagged entry never satisfies `any`/`all`. + assert.equal(matchesTags(tags, ['ci']), true); + assert.equal(matchesTags(tags, []), true); + assert.equal(matchesTags(undefined, ['perf'], 'any'), false); +}); + +test('entryFilter combines kind, host and tags with AND', () => { + const keep = entryFilter({ kind: 'lesson', tags: ['perf'], tags_mode: 'any' }); + assert.equal(keep({ kind: 'lesson', host: null, tags: ['perf'] }), true); + assert.equal(keep({ kind: 'bus', host: null, tags: ['perf'] }), false); + assert.equal(keep({ kind: 'lesson', host: null, tags: ['ci'] }), false); + // No filters → keeps everything. + assert.equal(entryFilter({})({ kind: null, host: null, tags: [] }), true); +}); + +test('parseTagsFilter defaults to any, validates the mode, and refuses a silent no-op', () => { + assert.deepEqual(parseTagsFilter({}), { filters: {} }); + assert.deepEqual(parseTagsFilter({ tags: ' perf, ci ,perf' }), { + filters: { tags: ['perf', 'ci'], tags_mode: 'any' }, + }); + assert.deepEqual(parseTagsFilter({ tags: 'perf', 'tags-mode': 'none' }), { + filters: { tags: ['perf'], tags_mode: 'none' }, + }); + assert.match(parseTagsFilter({ tags: 'perf', 'tags-mode': 'some' }).error, /--tags-mode must be one of any\|all\|none/); + assert.match(parseTagsFilter({ tags: true }).error, /--tags needs a comma-separated list/); + assert.match(parseTagsFilter({ tags: ' , ' }).error, /--tags needs a comma-separated list/); + assert.match(parseTagsFilter({ 'tags-mode': 'all' }).error, /--tags-mode needs --tags/); +}); + +test('gather narrows a local store by tags under each mode', async () => { + const dir = tmp('lk-list-tags-'); + const store = createLocalStore(dir); + await store.write({ scope: 'global', key: 'both', value: 'v', tags: ['perf', 'ci'] }); + await store.write({ scope: 'global', key: 'perf-only', value: 'v', tags: ['perf'] }); + await store.write({ scope: 'global', key: 'untagged', value: 'v' }); + + const keys = async (filters) => + (await gather(store, ['global'], filters)).groups[0].entries.map((e) => e.key).sort(); + + assert.deepEqual(await keys({ tags: ['perf', 'ci'], tags_mode: 'any' }), ['both', 'perf-only']); + assert.deepEqual(await keys({ tags: ['perf', 'ci'], tags_mode: 'all' }), ['both']); + assert.deepEqual(await keys({ tags: ['ci'], tags_mode: 'none' }), ['perf-only', 'untagged']); +}); + +test('the local store keeps requiring every tag when no mode is given', async () => { + // `search()` and other existing callers pass `tags` alone and rely on AND. + const dir = tmp('lk-list-tags-default-'); + const store = createLocalStore(dir); + await store.write({ scope: 'global', key: 'both', value: 'v', tags: ['perf', 'ci'] }); + await store.write({ scope: 'global', key: 'perf-only', value: 'v', tags: ['perf'] }); + const { entries } = await store.list({ scope: 'global', tags: ['perf', 'ci'] }); + assert.deepEqual(entries.map((e) => e.key), ['both']); +}); + +test('RemoteStore.list forwards tags and tags_mode to GET /memories', async () => { + const store = createRemoteStore({ endpoint: 'https://example.test/functions/v1/mcp', token: 'lk_ro_x' }); + const seen = []; + store._rest = async (p) => { seen.push(p); return { ok: true, data: { entries: [] } }; }; + + await store.list({ scope: 'global', tags: ['perf', 'ci'], tags_mode: 'none' }); + const params = new URL(seen[0], 'https://x').searchParams; + assert.equal(params.get('tags'), 'perf,ci'); + assert.equal(params.get('tags_mode'), 'none'); + + // A mode without tags is meaningless, so it is not sent. + await store.list({ scope: 'global', tags_mode: 'all' }); + assert.equal(new URL(seen[1], 'https://x').searchParams.has('tags_mode'), false); +}); + // ── integration: the real binary, no remote configured ──────────────────────── // A project with an opted-in project-tier store (.lorekit/) holding two lessons. @@ -127,7 +209,7 @@ function seedProject() { fs.mkdirSync(path.join(store, 'project', projectName), { recursive: true }); fs.writeFileSync( path.join(store, 'global', 'a.md'), - entry({ scope: 'global', key: 'prefer-guard-clauses', value: 'Use early returns.' }), + entry({ scope: 'global', key: 'prefer-guard-clauses', value: 'Use early returns.', tags: ['style'] }), ); fs.writeFileSync( path.join(store, 'project', projectName, 'b.md'), @@ -136,11 +218,11 @@ function seedProject() { return { root, home }; } -function entry({ scope, key, value }) { +function entry({ scope, key, value, tags = [] }) { const fm = { scope, key, - tags: [], + tags, source_agent: 'aw', trigger: 'manual', created: '2026-07-20T10:00:00.000Z', @@ -237,3 +319,33 @@ test('LOREKIT_DENY=local suppresses the offline section', () => { assert.equal(out.offline.available, false); assert.match(out.offline.reason, /deny constraint/); }); + +test('list --tags narrows both sections to tagged memories (default any)', () => { + const { root, home } = seedProject(); + const res = runList(root, home, ['--tags', 'style,perf', '--json']); + assert.equal(res.status, 0, res.stderr); + const out = JSON.parse(res.stdout); + const keys = out.offline.groups.flatMap((g) => g.entries.map((e) => e.key)); + assert.deepEqual(keys, ['prefer-guard-clauses']); + assert.equal(out.offline.total, 1); +}); + +test('list --tags-mode none excludes the tagged memory', () => { + const { root, home } = seedProject(); + const res = runList(root, home, ['--tags', 'style', '--tags-mode', 'none', '--json']); + assert.equal(res.status, 0, res.stderr); + const out = JSON.parse(res.stdout); + const keys = out.offline.groups.flatMap((g) => g.entries.map((e) => e.key)); + assert.deepEqual(keys, ['widget-build-flags']); +}); + +test('list rejects an invalid --tags-mode and a bare --tags instead of listing everything', () => { + const { root, home } = seedProject(); + const badMode = runList(root, home, ['--tags', 'style', '--tags-mode', 'some']); + assert.notEqual(badMode.status, 0); + assert.match(badMode.stderr, /--tags-mode must be one of any\|all\|none/); + + const bare = runList(root, home, ['--tags', '--json']); + assert.notEqual(bare.status, 0); + assert.match(bare.stderr, /--tags needs a comma-separated list/); +}); diff --git a/packages/cli/test/search.test.mjs b/packages/cli/test/search.test.mjs index d70d65563..87ffbe2f8 100644 --- a/packages/cli/test/search.test.mjs +++ b/packages/cli/test/search.test.mjs @@ -321,6 +321,30 @@ test('LOREKIT_DENY=remote suppresses the remote section, offline still matches', assert.equal(out.offline.total, 2); }); +test('search --tags only matches memories carrying the tag', () => { + const { root, home } = seedProject(); + // `early` hits only the `style`-tagged memory; `build` hits two memories, + // neither of which carries `style`. + const tagged = runSearch(root, home, ['early', '--tags', 'style', '--json']); + assert.equal(tagged.status, 0, tagged.stderr); + assert.equal(JSON.parse(tagged.stdout).offline.total, 1); + + const excluded = runSearch(root, home, ['build', '--tags', 'style', '--json']); + assert.equal(excluded.status, 0, excluded.stderr); + assert.equal(JSON.parse(excluded.stdout).offline.total, 0); + + const negated = runSearch(root, home, ['build', '--tags', 'style', '--tags-mode', 'none', '--json']); + assert.equal(negated.status, 0, negated.stderr); + assert.equal(JSON.parse(negated.stdout).offline.total, 2); +}); + +test('search rejects an invalid --tags-mode', () => { + const { root, home } = seedProject(); + const res = runSearch(root, home, ['build', '--tags', 'style', '--tags-mode', 'xor']); + assert.notEqual(res.status, 0); + assert.match(res.stderr, /--tags-mode must be one of any\|all\|none/); +}); + // ── integration: a configured (mock) remote ─────────────────────────────────── // A mock LoreKit REST endpoint answering GET /memories?scope=... from a fixture. diff --git a/packages/web/src/components/lore/FilterMenu.test.stories.tsx b/packages/web/src/components/lore/FilterMenu.test.stories.tsx index f4291b51a..273661376 100644 --- a/packages/web/src/components/lore/FilterMenu.test.stories.tsx +++ b/packages/web/src/components/lore/FilterMenu.test.stories.tsx @@ -102,7 +102,7 @@ export const ListsEveryDimensionFirst: Story = { // literals because menu ORDER is a deliberate design decision, not an // artefact of the array. await expect(rows).toHaveLength(FILTER_FIELDS.length); - await expect(rows[0]).toHaveTextContent('Label'); + await expect(rows[0]).toHaveTextContent('Tag'); await expect(rows[rows.length - 1]).toHaveTextContent('Pull request'); }); @@ -310,10 +310,10 @@ export const BackspaceAndStagedEscape: Story = { play: async ({ canvasElement, step }) => { const canvas = await openMenu(canvasElement); - await step('drill into Label and type a query', async () => { - await userEvent.click(await canvas.findByRole('option', { name: /^label/i })); + await step('drill into Tag and type a query', async () => { + await userEvent.click(await canvas.findByRole('option', { name: /^tag/i })); await userEvent.type( - canvas.getByRole('combobox', { name: /search label values/i }), + canvas.getByRole('combobox', { name: /search tag values/i }), 'auth', ); await waitFor(() => expect(canvas.getAllByRole('option')).toHaveLength(1)); @@ -322,7 +322,7 @@ export const BackspaceAndStagedEscape: Story = { await step('Escape clears the query first, keeping the level and the results', async () => { await userEvent.keyboard('{Escape}'); await waitFor(() => expect(canvas.getAllByRole('option').length).toBeGreaterThan(1)); - await expect(canvas.getByRole('listbox', { name: /label values/i })).toBeInTheDocument(); + await expect(canvas.getByRole('listbox', { name: /tag values/i })).toBeInTheDocument(); }); await step('Backspace on an empty query pops back to the dimension list', async () => { @@ -362,10 +362,10 @@ export const PillOperatorAndValueEditing: Story = { await step('a set-valued dimension gets its own operator vocabulary', async () => { const operator = await canvas.findByRole('button', { - name: /label includes all — change operator/i, + name: /tag includes all — change operator/i, }); await userEvent.click(operator); - const menu = await canvas.findByRole('listbox', { name: /label operator/i }); + const menu = await canvas.findByRole('listbox', { name: /tag operator/i }); await expect(within(menu).getAllByRole('option').map((o) => o.textContent)).toEqual([ 'includes all', 'includes any', @@ -376,7 +376,7 @@ export const PillOperatorAndValueEditing: Story = { await step('choosing an operator rewrites the pill in place', async () => { await userEvent.click(canvas.getByRole('option', { name: 'includes none' })); await waitFor(() => - expect(canvas.getByLabelText('Label includes none performance, auth')).toBeInTheDocument(), + expect(canvas.getByLabelText('Tag includes none performance, auth')).toBeInTheDocument(), ); }); @@ -384,7 +384,7 @@ export const PillOperatorAndValueEditing: Story = { await userEvent.click( canvas.getByRole('button', { name: /change values/i }), ); - await canvas.findByRole('listbox', { name: /label values/i }); + await canvas.findByRole('listbox', { name: /tag values/i }); await expect(await canvas.findByRole('option', { name: /performance/i })).toHaveAttribute( 'aria-selected', 'true', @@ -412,7 +412,7 @@ export const RemovingAndClearingFilters: Story = { const canvas = within(canvasElement); await step('the type segment is inert — it is text, not a control', async () => { - const typeSegments = canvas.queryAllByRole('button', { name: /^label$/i }); + const typeSegments = canvas.queryAllByRole('button', { name: /^tag$/i }); await expect(typeSegments).toHaveLength(0); }); @@ -423,7 +423,7 @@ export const RemovingAndClearingFilters: Story = { await waitFor(() => expect(canvas.queryByLabelText('Agent is claude')).not.toBeInTheDocument(), ); - await expect(canvas.getByLabelText('Label includes all performance')).toBeInTheDocument(); + await expect(canvas.getByLabelText('Tag includes all performance')).toBeInTheDocument(); }); await step('"Clear all" only exists while more than one condition does', async () => { @@ -450,13 +450,13 @@ export const PillOperatorEscapeDismissesWithoutLeaking: Story = { const canvas = within(document.body); const trigger = await canvas.findByRole('button', { - name: /label includes all — change operator/i, + name: /tag includes all — change operator/i, }); await step('the operator segment opens its listbox', async () => { await userEvent.click(trigger); await expect( - await canvas.findByRole('listbox', { name: /label operator/i }), + await canvas.findByRole('listbox', { name: /tag operator/i }), ).toBeInTheDocument(); }); @@ -471,7 +471,7 @@ export const PillOperatorEscapeDismissesWithoutLeaking: Story = { try { await userEvent.keyboard('{Escape}'); await waitFor(() => - expect(canvas.queryByRole('listbox', { name: /label operator/i })).not.toBeInTheDocument(), + expect(canvas.queryByRole('listbox', { name: /tag operator/i })).not.toBeInTheDocument(), ); await expect(docEscape).not.toHaveBeenCalled(); } finally { @@ -484,7 +484,7 @@ export const PillOperatorEscapeDismissesWithoutLeaking: Story = { }); await step('the condition itself is untouched by the dismissal', async () => { - await expect(canvas.getByLabelText('Label includes all performance')).toBeInTheDocument(); + await expect(canvas.getByLabelText('Tag includes all performance')).toBeInTheDocument(); }); }, }; @@ -515,7 +515,7 @@ export const DimensionListShowsPerGroupSelectionCount: Story = { await step('each filtered group announces its selection count', async () => { await expect( - canvas.getByRole('option', { name: /label, 2 selected/i }), + canvas.getByRole('option', { name: /tag, 2 selected/i }), ).toBeInTheDocument(); await expect( canvas.getByRole('option', { name: /agent, 1 selected/i }), @@ -1081,7 +1081,7 @@ export const CreatePolicyActionFollowsItsDestination: StoryObj { // Exact names: each pill's value segment repeats its phrase with a // "— change value" suffix, so a prefix match returns two per pill. - await expect(canvas.getByLabelText('Label includes any performance')).toBeInTheDocument(); + await expect(canvas.getByLabelText('Tag includes any performance')).toBeInTheDocument(); await expect(canvas.getByLabelText('Chosen: Never chosen')).toBeInTheDocument(); }); diff --git a/packages/web/src/components/lore/FilterMenu.tsx b/packages/web/src/components/lore/FilterMenu.tsx index f7e4a6df6..c08614d9a 100644 --- a/packages/web/src/components/lore/FilterMenu.tsx +++ b/packages/web/src/components/lore/FilterMenu.tsx @@ -1017,7 +1017,7 @@ export function FilterMenu({ role="option" aria-selected={i === activeIndex} // A count badge is aria-hidden decoration; fold it into the name so - // a screen-reader user hears "Label, 2 selected", not just "Label". + // a screen-reader user hears "Tag, 2 selected", not just "Tag". aria-label={ !isValueRow && selectedCount > 0 ? `${rowField.label}, ${selectedCount} selected` diff --git a/packages/web/src/content/docs/tags.mdx b/packages/web/src/content/docs/tags.mdx index 06bdffee8..cbf2fe092 100644 --- a/packages/web/src/content/docs/tags.mdx +++ b/packages/web/src/content/docs/tags.mdx @@ -83,7 +83,7 @@ The [Explorer](/lore) filters on nine dimensions, not just tags. Press the **Fil | Dimension | Comes from | Example | | --- | --- | --- | -| Label | `tags` | `loop::aw-lessons` | +| Tag | `tags` | `loop::aw-lessons` | | Kind | `kind` | `lesson` (also `bus`, `signal`) | | Host | `host` | `reviewer` | | Owner | `org_id` | `Personal` (or an org you belong to) | diff --git a/packages/web/src/lib/filters.spec.ts b/packages/web/src/lib/filters.spec.ts index 33484eb52..e675fe73e 100644 --- a/packages/web/src/lib/filters.spec.ts +++ b/packages/web/src/lib/filters.spec.ts @@ -284,7 +284,7 @@ describe('valueSummary / filterPhrase', () => { it('never truncates the phrase — an AT user has no +2 to hover', () => { expect( filterPhrase({ field: 'label', operator: 'all', values: ['a', 'b', 'c', 'd'] }), - ).toBe('Label includes all a, b, c, d'); + ).toBe('Tag includes all a, b, c, d'); }); it('joins the whole bar for the trigger name', () => { @@ -293,7 +293,7 @@ describe('valueSummary / filterPhrase', () => { { field: 'label', operator: 'all', values: ['perf'] }, { field: 'branch', operator: 'nin', values: ['main'] }, ]), - ).toBe('Label includes all perf; Branch is not main'); + ).toBe('Tag includes all perf; Branch is not main'); expect(filtersPhrase([])).toBe('Add filter'); }); }); @@ -355,6 +355,20 @@ describe('rootSuggestions', () => { expect(hits[0]).toEqual({ kind: 'field', field: 'branch' }); }); + // The `label` field is shown as "Tag". Someone typing `tags` (the API's word) + // or `label` (the menu's old word) must still land on it. + it.each(['tag', 'tags', 'Tags', 'label', 'labels'])( + 'finds the Tag dimension when the query is %s', + (query) => { + expect(rootSuggestions(FACETS, query)[0]).toEqual({ kind: 'field', field: 'label' }); + }, + ); + + it('shows the label field as "Tag" while keeping its serialised id', () => { + expect(fieldDescriptor('label')?.label).toBe('Tag'); + expect(fieldDescriptor('tag')).toBeUndefined(); + }); + it('caps the value hits so an unbounded catalog cannot flood the menu', () => { const many: FacetValue[] = Array.from({ length: 30 }, (_, i) => ({ facet: 'origin_branch' as const, diff --git a/packages/web/src/lib/filters.ts b/packages/web/src/lib/filters.ts index 7510ecfb6..b35f26bab 100644 --- a/packages/web/src/lib/filters.ts +++ b/packages/web/src/lib/filters.ts @@ -111,6 +111,11 @@ export interface FilterFieldDescriptor { field: FilterField; /** The word in the type list and in the pill's first segment. */ label: string; + /** + * Other words a user may type for this dimension in the menu's search box. + * Matched exactly like {@link FilterFieldDescriptor.label}, never shown. + */ + aliases?: readonly string[]; /** Placeholder for the value-list search box. */ searchPlaceholder: string; /** The facet whose values populate this field. */ @@ -135,12 +140,20 @@ export interface FilterFieldDescriptor { * memories with every selected label"), and silently flipping a live filter's * semantics on upgrade would change what an already-shared `?tags=` link * returns. `includes any` is one click away in the operator menu. + * + * The `label` field is SHOWN as "Tag". Every other surface — the lesson detail + * sheet, the docs, the MCP tools, the CLI's `--tags` — calls these tags, and a + * menu that said "Label" read as "this Explorer cannot filter by tags". The + * field id stays `label` because it is serialised into `?filters=` links, so + * renaming it would break every link already shared; "label" stays searchable + * as an alias for anyone who learned the old word. */ export const FILTER_FIELDS: readonly FilterFieldDescriptor[] = [ { field: 'label', - label: 'Label', - searchPlaceholder: 'Search labels…', + label: 'Tag', + aliases: ['tags', 'label', 'labels'], + searchPlaceholder: 'Search tags…', facet: 'tag', operators: ['all', 'in', 'nin'], format: (v) => v, @@ -646,13 +659,17 @@ export function rootSuggestions( if (!needle) return FILTER_FIELDS.map((d) => ({ kind: 'field' as const, field: d.field })); - const fieldRank = (d: FilterFieldDescriptor): number => { - const name = d.label.toLowerCase(); + const nameRank = (word: string): number => { + const name = word.toLowerCase(); if (name === needle) return 0; if (name.startsWith(needle)) return 1; if (name.includes(needle)) return 2; return Number.POSITIVE_INFINITY; }; + // The best rank across the shown word and its aliases, so typing `tags` or + // `label` finds the Tag dimension as surely as typing `tag` does. + const fieldRank = (d: FilterFieldDescriptor): number => + Math.min(nameRank(d.label), ...(d.aliases ?? []).map(nameRank)); const fieldHits: RootSuggestion[] = FILTER_FIELDS .map((d) => ({ d, rank: fieldRank(d) })) diff --git a/packages/web/src/lib/tag-filter.ts b/packages/web/src/lib/tag-filter.ts index a5f848008..945a090f0 100644 --- a/packages/web/src/lib/tag-filter.ts +++ b/packages/web/src/lib/tag-filter.ts @@ -1,9 +1,9 @@ /** * Pure logic for the Lore Explorer's label (tag) filter. * - * "Label" is the user-facing word for what the database stores as - * `memories.tags` — the dashboard copy says Labels, the schema says tags, and - * this module is the seam between the two vocabularies. + * The code here says "label" for what the database stores as `memories.tags`. + * The dashboard now shows the word "Tag" for them too (see `FILTER_FIELDS` in + * `filters.ts`), so "label" survives only as an internal name. * * Kept dependency-free (no React, no Supabase) so every decision the filter * makes is unit-testable in the node vitest project, mirroring `org-ui.ts` From 7f4cefc4bb4c6427f3b596a778a6ce78dab605ed Mon Sep 17 00:00:00 2001 From: "mads.thines" Date: Tue, 20 Oct 2026 13:04:07 +0000 Subject: [PATCH 2/9] feat(mcp): accept a tags filter on memory.list_archived Matches memory.list's semantics (ANY of the tags). Adds a live smoke check. Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com> --- packages/cli/src/surfaces.generated.mjs | 7 +++++++ packages/schemas/src/shared/tool-catalog.ts | 12 ++++++++++-- scripts/smoke/smoke-mcp-tools.mjs | 15 +++++++++++++++ .../functions/_shared/schemas/tool-catalog.ts | 12 ++++++++++-- supabase/functions/mcp/tools.ts | 9 ++++++++- 5 files changed, 50 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/surfaces.generated.mjs b/packages/cli/src/surfaces.generated.mjs index c4688fddf..ff87df99a 100644 --- a/packages/cli/src/surfaces.generated.mjs +++ b/packages/cli/src/surfaces.generated.mjs @@ -397,6 +397,13 @@ export const MCP_TOOL_DEFS = [ "type": "string", "description": "Canonical scope string, e.g. `repo::mthines/lorekit`. OPTIONAL — omit it to search every scope you can see." }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Filter to entries carrying ANY of these labels (OR)." + }, "limit": { "type": "integer", "minimum": 1, diff --git a/packages/schemas/src/shared/tool-catalog.ts b/packages/schemas/src/shared/tool-catalog.ts index 3f3738b94..0a9aa1de7 100644 --- a/packages/schemas/src/shared/tool-catalog.ts +++ b/packages/schemas/src/shared/tool-catalog.ts @@ -518,8 +518,16 @@ export const MCP_TOOLS = [ }, // `scope` optional for the same reason as `memory.list`, whose // `?archived=true` form this is the MCP twin of — the two must not disagree - // about whether a scope is needed to look at the same rows. - inputSchema: { type: 'object', properties: { scope: readScope, limit } }, + // about whether a scope is needed to look at the same rows. `tags` carries + // `memory.list`'s exact semantics (ANY, OR) for the same reason. + inputSchema: { + type: 'object', + properties: { + scope: readScope, + tags: { type: 'array', items: { type: 'string' }, description: 'Filter to entries carrying ANY of these labels (OR).' }, + limit, + }, + }, returns: '`{ "entries": [{ "scope", "key", "value", "tags", "updated_at", "archived_at" }] }` — every entry names its own `scope`, so an unscoped listing stays readable.', }, { diff --git a/scripts/smoke/smoke-mcp-tools.mjs b/scripts/smoke/smoke-mcp-tools.mjs index 4de924d9e..b5f9c0839 100644 --- a/scripts/smoke/smoke-mcp-tools.mjs +++ b/scripts/smoke/smoke-mcp-tools.mjs @@ -668,6 +668,21 @@ check('archive hides a row from reads but keeps it listable and restorable', asy ); }); +check('list_archived narrows by tags, matching ANY of them like memory.list', async () => { + await write(SCOPE, 'archived-tagged', { tags: ['smoke::archived-tag'] }); + await write(SCOPE, 'archived-untagged'); + await call('memory.archive', { scope: SCOPE, key: 'archived-tagged' }); + await call('memory.archive', { scope: SCOPE, key: 'archived-untagged' }); + + const r = await call('memory.list_archived', { + scope: SCOPE, + tags: ['smoke::archived-tag', 'smoke::no-such-tag'], + }); + const keys = (r.value?.entries ?? []).map((e) => e.key); + ok(keys.includes('archived-tagged'), 'the tagged archived row is missing from a tags-filtered list_archived'); + ok(!keys.includes('archived-untagged'), 'an untagged archived row survived the tags filter'); +}); + check('restoring a row that is not archived reports not_found, not success', async () => { const r = await call('memory.restore', { scope: SCOPE, key: 'archivable' }); eq(r.value?.restored, false, 'restored'); diff --git a/supabase/functions/_shared/schemas/tool-catalog.ts b/supabase/functions/_shared/schemas/tool-catalog.ts index c5c0fb733..4e162fc30 100644 --- a/supabase/functions/_shared/schemas/tool-catalog.ts +++ b/supabase/functions/_shared/schemas/tool-catalog.ts @@ -523,8 +523,16 @@ export const MCP_TOOLS = [ }, // `scope` optional for the same reason as `memory.list`, whose // `?archived=true` form this is the MCP twin of — the two must not disagree - // about whether a scope is needed to look at the same rows. - inputSchema: { type: 'object', properties: { scope: readScope, limit } }, + // about whether a scope is needed to look at the same rows. `tags` carries + // `memory.list`'s exact semantics (ANY, OR) for the same reason. + inputSchema: { + type: 'object', + properties: { + scope: readScope, + tags: { type: 'array', items: { type: 'string' }, description: 'Filter to entries carrying ANY of these labels (OR).' }, + limit, + }, + }, returns: '`{ "entries": [{ "scope", "key", "value", "tags", "updated_at", "archived_at" }] }` — every entry names its own `scope`, so an unscoped listing stays readable.', }, { diff --git a/supabase/functions/mcp/tools.ts b/supabase/functions/mcp/tools.ts index b6236bcaa..ebbcea9f5 100644 --- a/supabase/functions/mcp/tools.ts +++ b/supabase/functions/mcp/tools.ts @@ -889,7 +889,11 @@ export async function toolListArchived( span: Span, keyScoping?: KeyRestriction, ) { - const { scope: rawScope, limit = 50 } = params; + const { scope: rawScope, tags: rawTags, limit = 50 } = params; + // `tags` narrows exactly as it does on `memory.list` (ANY of them, OR) — the + // archived twin used to take no tag filter, so finding one archived lesson + // among many meant paging the whole archive. + const tags = toTagList(rawTags); // Optional for the same reason as `memory.list`, whose `?archived=true` form // this is the MCP twin of — the two must not disagree about whether a scope // is needed to look at the same rows. @@ -909,6 +913,8 @@ export async function toolListArchived( 'lorekit.list.unscoped': scope === null, 'lorekit.requested_limit': limit, 'lorekit.limit_capped': limit > pageLimit, + // A count, never the tag values — bounded, and no lesson content leaks. + 'lorekit.tags.count': tags.length, }); const tracedDb = createTracedClient(db, span); @@ -924,6 +930,7 @@ export async function toolListArchived( .limit(pageLimit); if (scope) query = query.eq('scope', scope); if (userId) query = applyTenantScope(query, userId, await memberOrgIds(db, userId), keyScoping); + if (tags.length) query = query.overlaps('tags', pgArrayLiteral(tags)); const { data, error } = await query; if (error) throw new Error(error.message); const rows = data ?? []; From a52ad338f3d70320fe43159455379a795a9a7f45 Mon Sep 17 00:00:00 2001 From: "mads.thines" Date: Tue, 20 Oct 2026 13:05:03 +0000 Subject: [PATCH 3/9] docs: document tag filters on list/search and memory.list_archived; regenerate llms.txt Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com> --- docs/cli.md | 10 ++++++++-- docs/mcp-tools.md | 2 ++ packages/schemas/src/llms/template.md | 2 +- packages/web/public/llms.txt | 3 ++- packages/web/src/content/docs/tags.mdx | 6 +++++- 5 files changed, 18 insertions(+), 5 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index e22d9b18d..8ce0685ab 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -96,7 +96,12 @@ without moving its version. See Alias `ls` — read-only human view of the lessons for the cwd's scopes, project/branch/repo/global, split into an **Offline** local-store section and a **Remote** hosted section that degrades -gracefully when unconfigured; `--json`/`--scope`; `--all` drains all pages via `gatherStream` +gracefully when unconfigured; `--json`/`--scope`; `--kind`/`--host` narrow by taxonomy; +`--tags a,b` narrows by tags, combined by `--tags-mode any|all|none` [default `any`, the hosted +`GET /memories` default] — forwarded to the remote AND applied to both stores by the one +`entryFilter` predicate in `lessons-view.mjs` (the pure `matchesTags` lives in `lessons-pure.mjs` +because the local store applies it too), and a bare `--tags` or a `--tags-mode` without `--tags` +is a usage error rather than an unfiltered listing; `--all` drains all pages via `gatherStream` [default is single-page `gather()`]; `--max ` hard-caps entries surveyed [default 5000]; `--since`/`--until ` forward `created_since`/`created_until` to the REST request; independently builds BOTH stores via `src/shared/stores.mjs` + renders via the reusable @@ -111,7 +116,8 @@ case-insensitive** substring against a lesson's key OR value, via the pure `matc dependency-free `lessons-pure.mjs`, re-exported by `lessons-view.mjs`)/`filterGroups` helpers over the shared `gather()` output — NOT each store's native `search` method, whose semantics diverge (local literal-substring vs remote server-FTS); regex metachars are matched verbatim, never -compiled; `--json`/`--scope` +compiled; `--json`/`--scope`; `--tags a,b [--tags-mode any|all|none]` narrows the candidates +before the query is matched, exactly as on `list` ### `show` diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index 02ed50ab7..2b947a267 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -536,6 +536,7 @@ List soft-archived lessons for a scope, newest archived first. Requires a token "name": "memory.list_archived", "arguments": { "scope": "global", + "tags": ["loop::aw-lessons"], "limit": 20 } } @@ -545,6 +546,7 @@ List soft-archived lessons for a scope, newest archived first. Requires a token | Argument | Default | Description | |----------|---------|-------------| | `scope` | all visible scopes | Scope to list archived entries for. Omit to list across every scope you can see | +| `tags` | | Only entries carrying ANY of these tags (OR) — the same semantics as `memory.list` | | `limit` | `50` | Max results (cap: 100) | **Returns:** `{ "entries": [{ "scope", "key", "value", "tags", "updated_at", "archived_at" }] }` diff --git a/packages/schemas/src/llms/template.md b/packages/schemas/src/llms/template.md index 1192fd1bb..06bfac120 100644 --- a/packages/schemas/src/llms/template.md +++ b/packages/schemas/src/llms/template.md @@ -193,7 +193,7 @@ view is a shareable link. | Dimension | Memory field | Operators | |-----------|--------------|-----------| -| Label | `tags` | includes all (default), includes any, includes none | +| Tag | `tags` | includes all (default), includes any, includes none | | Kind | `kind` | is / is either of, is not | | Host | `host` | is / is either of, is not | | Agent | `source_agent` | is / is either of, is not | diff --git a/packages/web/public/llms.txt b/packages/web/public/llms.txt index b1a79b04f..018ca82b7 100644 --- a/packages/web/public/llms.txt +++ b/packages/web/public/llms.txt @@ -371,6 +371,7 @@ List archived (soft-deleted) lessons, for one scope or across every scope you ca | Argument | Required | Type | Description | |----------|----------|------|-------------| | `scope` | | string | Canonical scope string, e.g. `repo::mthines/lorekit`. OPTIONAL — omit it to search every scope you can see. | +| `tags` | | array | Filter to entries carrying ANY of these labels (OR). _(array of string)_ | | `limit` | | integer | Maximum entries to return. _(1–100, default `50`)_ | Requires **read** permission. @@ -720,7 +721,7 @@ view is a shareable link. | Dimension | Memory field | Operators | |-----------|--------------|-----------| -| Label | `tags` | includes all (default), includes any, includes none | +| Tag | `tags` | includes all (default), includes any, includes none | | Kind | `kind` | is / is either of, is not | | Host | `host` | is / is either of, is not | | Agent | `source_agent` | is / is either of, is not | diff --git a/packages/web/src/content/docs/tags.mdx b/packages/web/src/content/docs/tags.mdx index cbf2fe092..c2aad325f 100644 --- a/packages/web/src/content/docs/tags.mdx +++ b/packages/web/src/content/docs/tags.mdx @@ -182,8 +182,12 @@ The CLI offers several commands that respect the same scope/tag logic. You can a # Human-readable view of all applicable memories npx @lorekit/cli list -# Full-text search +# Only memories carrying a tag (any of a comma list; --tags-mode all|none to change that) +npx @lorekit/cli list --tags loop::aw-lessons + +# Full-text search, optionally narrowed by tag npx @lorekit/cli search "worktree" +npx @lorekit/cli search "worktree" --tags skill::aw # Inspect one memory in full npx @lorekit/cli show --scope global --key aw-lessons::worktree-naming From c60cda6f61f8d22f24a42d7bc8380465c3645456 Mon Sep 17 00:00:00 2001 From: "mads.thines" Date: Tue, 20 Oct 2026 13:05:44 +0000 Subject: [PATCH 4/9] test(mcp-core): count list_archived among the tag-coercing edge read handlers Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com> --- packages/mcp-core/src/rest/edge-tag-filter-quoting.spec.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/mcp-core/src/rest/edge-tag-filter-quoting.spec.ts b/packages/mcp-core/src/rest/edge-tag-filter-quoting.spec.ts index 230657335..272e1cea7 100644 --- a/packages/mcp-core/src/rest/edge-tag-filter-quoting.spec.ts +++ b/packages/mcp-core/src/rest/edge-tag-filter-quoting.spec.ts @@ -46,9 +46,10 @@ describe('edge MCP tag filters', () => { it('coerces the raw JSON-RPC tags param through toTagList', () => { expect(source).toContain("import { pgArrayLiteral, resolveKindHost, toTagList } from '../_shared/schemas/tags.ts';"); - // Both read handlers destructure the untrusted value under a `rawTags` - // alias and normalise it, so no `tags` identifier in a query is unchecked. + // Every tag-filtering read handler — `toolList`, `toolSearch` and + // `toolListArchived` — destructures the untrusted value under a `rawTags` + // alias and normalises it, so no `tags` identifier in a query is unchecked. const coercions = [...source.matchAll(/const tags = toTagList\(rawTags\);/g)]; - expect(coercions.length).toBe(2); + expect(coercions.length).toBe(3); }); }); From 283104a7d6770fc01ced928b1b1599f806d63a86 Mon Sep 17 00:00:00 2001 From: "mads.thines" Date: Tue, 20 Oct 2026 14:03:21 +0000 Subject: [PATCH 5/9] feat(cli): add --kind-mode/--host-mode to list and search, and --kind/--host to search parseReadFilters replaces parseTagsFilter and parses tags, kind and host with their modes from the shared DIMENSIONS table. The local post-filter follows lorekit_match_text: nin requires a set value. RemoteStore.list forwards kind_mode/host_mode. Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com> --- packages/cli/bin/lorekit.mjs | 11 +++ packages/cli/src/commands/list.mjs | 21 +++-- packages/cli/src/commands/search.mjs | 19 ++-- packages/cli/src/shared/completions.mjs | 8 +- packages/cli/src/shared/flags.mjs | 70 ++++++++++----- packages/cli/src/shared/lessons-view.mjs | 25 ++++-- packages/cli/src/store/remote.mjs | 19 +++- packages/cli/test/list.test.mjs | 106 ++++++++++++++++++++--- packages/cli/test/search.test.mjs | 30 ++++++- 9 files changed, 238 insertions(+), 71 deletions(-) diff --git a/packages/cli/bin/lorekit.mjs b/packages/cli/bin/lorekit.mjs index 8fac286b9..20053aa54 100755 --- a/packages/cli/bin/lorekit.mjs +++ b/packages/cli/bin/lorekit.mjs @@ -350,6 +350,10 @@ short note on how to set it up — never an error. ${c.bold('Options')} -d, --dir Target project root (default: current directory) --scope Restrict to a single scope (default: all applicable) + --kind Only memories of these kinds (lesson, bus, signal) + --kind-mode in (is one of them, default) | nin (is none of them) + --host Only memories owned by these hosts (e.g. reviewer, aw) + --host-mode in (is one of them, default) | nin (is none of them) --tags Only memories carrying these tags (combined by --tags-mode) --tags-mode any (at least one, default) | all (every one) | none --json Machine-readable output @@ -364,6 +368,8 @@ ${c.bold('Examples')} npx @lorekit/cli list --scope global npx @lorekit/cli list --tags loop::aw-lessons npx @lorekit/cli list --tags perf,ci --tags-mode all + npx @lorekit/cli list --kind lesson --host reviewer + npx @lorekit/cli list --kind bus --kind-mode nin npx @lorekit/cli list --scope global --link `, search: `${c.bold('lorekit search')} — full-text search the applicable memories ${c.dim('(alias: grep)')} @@ -380,6 +386,10 @@ never interpreted). Results are shown in the same Offline / Remote split as ${c.bold('Options')} -d, --dir Target project root (default: current directory) --scope Restrict to a single scope (default: all applicable) + --kind Only search memories of these kinds (lesson, bus, signal) + --kind-mode in (is one of them, default) | nin (is none of them) + --host Only search memories owned by these hosts (e.g. reviewer, aw) + --host-mode in (is one of them, default) | nin (is none of them) --tags Only search memories carrying these tags (combined by --tags-mode) --tags-mode any (at least one, default) | all (every one) | none --json Machine-readable output @@ -393,6 +403,7 @@ ${c.bold('Examples')} npx @lorekit/cli grep "flaky test" --json npx @lorekit/cli search migration --scope global npx @lorekit/cli search migration --tags loop::aw-lessons + npx @lorekit/cli search flaky --kind lesson --host-mode nin --host ci npx @lorekit/cli search "flaky test" --scope global --link `, write: `${c.bold('lorekit write')} — create or update a memory from the CLI diff --git a/packages/cli/src/commands/list.mjs b/packages/cli/src/commands/list.mjs index 778bd8d81..08fda4640 100644 --- a/packages/cli/src/commands/list.mjs +++ b/packages/cli/src/commands/list.mjs @@ -15,7 +15,7 @@ import { resolveDenies } from '../shared/control.mjs'; import { resolveStores, remoteUnavailableReason } from '../shared/stores.mjs'; import { scopeList, gather, gatherStream, renderSection, entryFilter, DEFAULT_MAX } from '../shared/lessons-view.mjs'; import { resolveAppBase, mostSpecificScope } from '../shared/deeplink-pure.mjs'; -import { parseTagsFilter } from '../shared/flags.mjs'; +import { parseReadFilters } from '../shared/flags.mjs'; import { emitLink } from './link.mjs'; import { log, err, heading, c } from '../shared/util.mjs'; @@ -42,18 +42,15 @@ export async function list(args) { // Default to every applicable scope; `--scope ` narrows to one (an explicit // scope outside the applicable set is honoured — the user asked for it). const scopes = args.scope && typeof args.scope === 'string' ? [args.scope] : scopeList(scopeInfo); - // Optional taxonomy filters — `--kind lesson --host reviewer` narrows to one - // family/owner. Comma lists are honoured by the remote store's query builder. - const filters = {}; - if (typeof args.kind === 'string') filters.kind = args.kind; - if (typeof args.host === 'string') filters.host = args.host; - // `--tags a,b [--tags-mode any|all|none]` — same semantics as `memory.list`. - const tagFilter = parseTagsFilter(args); - if (tagFilter.error) { - err(`${c.red('Error:')} ${tagFilter.error}`); + // Optional dimension filters, ANDed — `--kind lesson --host reviewer` narrows + // to one family/owner, `--kind-mode nin` / `--host-mode nin` negate, and + // `--tags a,b [--tags-mode any|all|none]` matches `memory.list`'s semantics. + const parsed = parseReadFilters(args); + if (parsed.error) { + err(`${c.red('Error:')} ${parsed.error}`); return 1; } - Object.assign(filters, tagFilter.filters); + const filters = parsed.filters; // `--link` short-circuits: print the Explorer deep link for the current // context (the most-specific applicable scope, or `--scope`), no store reads. @@ -160,6 +157,8 @@ export async function list(args) { 'lorekit.cli.list.remote_count': remoteResult.total, 'lorekit.cli.list.remote_available': remoteAvailable, 'lorekit.cli.list.tags_filtered': Boolean(filters.tags), + 'lorekit.cli.list.kind_filtered': Boolean(filters.kind), + 'lorekit.cli.list.host_filtered': Boolean(filters.host), }; } diff --git a/packages/cli/src/commands/search.mjs b/packages/cli/src/commands/search.mjs index 9ebab0ae0..e9d933394 100644 --- a/packages/cli/src/commands/search.mjs +++ b/packages/cli/src/commands/search.mjs @@ -20,7 +20,7 @@ import { resolveDenies } from '../shared/control.mjs'; import { resolveStores, remoteUnavailableReason } from '../shared/stores.mjs'; import { scopeList, gather, filterGroups, renderSection } from '../shared/lessons-view.mjs'; import { resolveAppBase, mostSpecificScope } from '../shared/deeplink-pure.mjs'; -import { parseTagsFilter } from '../shared/flags.mjs'; +import { parseReadFilters } from '../shared/flags.mjs'; import { emitLink } from './link.mjs'; import { log, err, heading, c } from '../shared/util.mjs'; @@ -34,7 +34,7 @@ export async function search(args) { // and would otherwise degrade to `list`. const query = typeof args._[1] === 'string' ? args._[1] : ''; if (!query.trim()) { - err(`${c.red('Usage:')} lorekit search [--scope ] [--tags [--tags-mode any|all|none]] [--json]`); + err(`${c.red('Usage:')} lorekit search [--scope ] [--kind|--host [--kind-mode|--host-mode in|nin]] [--tags [--tags-mode any|all|none]] [--json]`); err(`Provide a search term. Run ${c.cyan('lorekit search --help')} for options.`); return 1; } @@ -44,14 +44,15 @@ export async function search(args) { // scope outside the applicable set is honoured — the user asked for it). const scopes = args.scope && typeof args.scope === 'string' ? [args.scope] : scopeList(scopeInfo); - // `--tags a,b [--tags-mode any|all|none]` narrows the candidate set before the - // query is matched — the same filter, and the same semantics, as `list`. - const tagFilter = parseTagsFilter(args); - if (tagFilter.error) { - err(`${c.red('Error:')} ${tagFilter.error}`); + // `--kind`/`--host` (+ `--kind-mode`/`--host-mode in|nin`) and `--tags` + // (+ `--tags-mode any|all|none`) narrow the candidate set before the query is + // matched — the same filters, with the same semantics, as `list`. + const parsed = parseReadFilters(args); + if (parsed.error) { + err(`${c.red('Error:')} ${parsed.error}`); return 1; } - const filters = tagFilter.filters; + const filters = parsed.filters; // `--link` short-circuits: print the Explorer deep link for this search // (`?q=…` plus the most-specific applicable scope), without touching a store. @@ -119,6 +120,8 @@ export async function search(args) { 'lorekit.cli.search.remote_matches': remoteResult.total, 'lorekit.cli.search.remote_available': remoteAvailable, 'lorekit.cli.search.tags_filtered': Boolean(filters.tags), + 'lorekit.cli.search.kind_filtered': Boolean(filters.kind), + 'lorekit.cli.search.host_filtered': Boolean(filters.host), }; } diff --git a/packages/cli/src/shared/completions.mjs b/packages/cli/src/shared/completions.mjs index 8c2f25f2c..19e2ca88f 100644 --- a/packages/cli/src/shared/completions.mjs +++ b/packages/cli/src/shared/completions.mjs @@ -52,6 +52,10 @@ const FLAG = { value: { desc: 'Memory value', arg: 'text' }, tags: { desc: 'Comma-separated tags', arg: 'a,b,c' }, 'tags-mode': { desc: 'How --tags combine', arg: 'mode', values: ['any', 'all', 'none'] }, + kind: { desc: 'Bucket kind filter', arg: 'a,b', values: ['lesson', 'bus', 'signal'] }, + 'kind-mode': { desc: 'How --kind matches', arg: 'mode', values: ['in', 'nin'] }, + host: { desc: 'Owning host filter', arg: 'a,b' }, + 'host-mode': { desc: 'How --host matches', arg: 'mode', values: ['in', 'nin'] }, 'source-agent': { desc: 'Source agent name to record', arg: 'name' }, trigger: { desc: 'Trigger context slug', arg: 'slug' }, 'ttl-days': { desc: 'Days until auto-expiry (1..365)', arg: 'n' }, @@ -112,10 +116,10 @@ const COMMANDS = [ { name: 'update', summary: 'Offline refresh of the bundled skills to the shipped version', flags: ['dir', 'project', 'global', 'check'] }, { name: 'list', summary: 'List memories for the current directory', aliases: ['ls'], - flags: ['dir', 'scope', 'tags', 'tags-mode', 'json', 'endpoint', 'token', 'store', 'link', 'base'] }, + flags: ['dir', 'scope', 'kind', 'kind-mode', 'host', 'host-mode', 'tags', 'tags-mode', 'json', 'endpoint', 'token', 'store', 'link', 'base'] }, { name: 'search', summary: 'Full-text search the applicable memories', aliases: ['grep'], positional: 'query', - flags: ['dir', 'scope', 'tags', 'tags-mode', 'json', 'endpoint', 'token', 'store', 'link', 'base'] }, + flags: ['dir', 'scope', 'kind', 'kind-mode', 'host', 'host-mode', 'tags', 'tags-mode', 'json', 'endpoint', 'token', 'store', 'link', 'base'] }, { name: 'show', summary: 'Inspect one memory in full', positional: 'address', flags: ['dir', 'json', 'endpoint', 'token', 'store', 'scope', 'key', 'link', 'base'] }, { name: 'stats', summary: 'Count memories per scope and store', diff --git a/packages/cli/src/shared/flags.mjs b/packages/cli/src/shared/flags.mjs index db254c8f3..8162b7639 100644 --- a/packages/cli/src/shared/flags.mjs +++ b/packages/cli/src/shared/flags.mjs @@ -89,34 +89,56 @@ export function parseDimensionConditions(args, { clearable = false } = {}) { } /** - * Parse the read commands' tag filter — `--tags a,b [--tags-mode any|all|none]` - * on `list` and `search`. + * The dimensions the read commands (`list`, `search`) filter on, in the order + * they are checked, with an example value list for the usage error. Each + * reuses its {@link DIMENSIONS} row for the field name and the valid modes, so + * `--kind-mode` accepts exactly what `policy create --kind-mode` accepts. + */ +const READ_FILTERS = [ + { flag: 'tags', example: 'loop::aw-lessons,perf' }, + { flag: 'kind', example: 'lesson,bus' }, + { flag: 'host', example: 'reviewer,aw' }, +]; + +/** + * Parse the read commands' dimension filters — `--tags a,b [--tags-mode + * any|all|none]`, `--kind a,b [--kind-mode in|nin]` and `--host a,b + * [--host-mode in|nin]` on `list` and `search`. * - * Returns `{ filters: {} }` when `--tags` was not given, `{ filters: { tags, - * tags_mode } }` when it was (the mode defaulting to `any`, the hosted list's - * own default, so offline and remote agree), or `{ error }` naming the flag. + * Returns `{ filters }` holding only the dimensions that were given, each as a + * value array plus its `*_mode` — defaulting to the dimension's first mode + * (`any` for tags, `in` for kind/host), the hosted list's own defaults, so + * offline and remote agree — or `{ error }` naming the offending flag. * * Stricter than {@link parseDimensionConditions} on purpose: a bare `--tags` - * with no value, or a `--tags-mode` with no `--tags`, is rejected rather than - * quietly listing everything — that silent no-op is the bug this flag exists - * to fix (`tags` was already a known flag for `write`, so `list --tags x` used - * to parse cleanly and filter nothing). + * with no value, or a `--kind-mode` with no `--kind`, is rejected rather than + * quietly listing everything. That silent no-op is how `list --tags x` used to + * behave (`tags` was a known flag for `write`, so it parsed cleanly and + * filtered nothing). */ -export function parseTagsFilter(args) { - const { modes } = DIMENSIONS.find((d) => d.flag === 'tags'); - const raw = args.tags; - const modeRaw = args['tags-mode']; +export function parseReadFilters(args) { + const filters = {}; + for (const { flag, example } of READ_FILTERS) { + const { field, modes } = DIMENSIONS.find((d) => d.flag === flag); + const raw = args[flag]; + const modeRaw = args[`${flag}-mode`]; - if (modeRaw !== undefined && !modes.includes(modeRaw)) { - return { error: `--tags-mode must be one of ${modes.join('|')}, got ${JSON.stringify(String(modeRaw))}` }; - } - if (raw === undefined) { - return modeRaw === undefined ? { filters: {} } : { error: '--tags-mode needs --tags ' }; - } + if (modeRaw !== undefined && !modes.includes(modeRaw)) { + return { error: `--${flag}-mode must be one of ${modes.join('|')}, got ${JSON.stringify(String(modeRaw))}` }; + } + if (raw === undefined) { + if (modeRaw !== undefined) return { error: `--${flag}-mode needs --${flag} ` }; + continue; + } - const tags = typeof raw === 'string' - ? [...new Set(raw.split(',').map((s) => s.trim()).filter((s) => s.length > 0))] - : []; - if (tags.length === 0) return { error: '--tags needs a comma-separated list, e.g. --tags loop::aw-lessons,perf' }; - return { filters: { tags, tags_mode: modeRaw ?? 'any' } }; + const values = typeof raw === 'string' + ? [...new Set(raw.split(',').map((s) => s.trim()).filter((s) => s.length > 0))] + : []; + if (values.length === 0) { + return { error: `--${flag} needs a comma-separated list, e.g. --${flag} ${example}` }; + } + filters[field] = values; + filters[`${field}_mode`] = modeRaw ?? modes[0]; + } + return { filters }; } diff --git a/packages/cli/src/shared/lessons-view.mjs b/packages/cli/src/shared/lessons-view.mjs index 585ae6e5e..cc1c98ade 100644 --- a/packages/cli/src/shared/lessons-view.mjs +++ b/packages/cli/src/shared/lessons-view.mjs @@ -600,20 +600,31 @@ function valueSet(v) { return set.size > 0 ? set : null; } +// Whether a scalar value (kind, host) satisfies a value-set filter under `mode` +// — the hosted `lorekit_match_text` rule (migration 00066): `in` = is one of +// them, `nin` = is set AND is none of them. A row with no value matches +// NEITHER mode, so `--kind-mode nin` never surfaces an unclassified row the +// remote would have excluded. A missing filter matches everything. Pure. +export function matchesScalar(value, wanted, mode = 'in') { + if (!wanted) return true; + if (value == null) return false; + return mode === 'nin' ? !wanted.has(value) : wanted.has(value); +} + // The `keep` predicate for normalized entries under the read commands' filters -// (`kind`, `host`, `tags` + `tags_mode`). ONE predicate for every path that -// narrows a listing — `gather()`, and `list --all`'s streamed drain — so the -// offline store, the remote store and a full drain can never disagree about -// what a filter means. Kind/host match the taxonomy `normalizeEntry` inferred -// for a row without explicit columns. Pure. +// (`kind` + `kind_mode`, `host` + `host_mode`, `tags` + `tags_mode`), ANDed. +// ONE predicate for every path that narrows a listing — `gather()`, and +// `list --all`'s streamed drain — so the offline store, the remote store and a +// full drain can never disagree about what a filter means. Kind/host match the +// taxonomy `normalizeEntry` inferred for a row without explicit columns. Pure. export function entryFilter(filters = {}) { const kindSet = valueSet(filters.kind); const hostSet = valueSet(filters.host); const tagSet = valueSet(filters.tags); const tagsMode = filters.tags_mode ?? DEFAULT_TAGS_MODE; return (e) => - (!kindSet || (e.kind != null && kindSet.has(e.kind))) && - (!hostSet || (e.host != null && hostSet.has(e.host))) && + matchesScalar(e.kind, kindSet, filters.kind_mode) && + matchesScalar(e.host, hostSet, filters.host_mode) && matchesTags(e.tags, tagSet, tagsMode); } diff --git a/packages/cli/src/store/remote.mjs b/packages/cli/src/store/remote.mjs index 6be2e605d..1b69e359a 100644 --- a/packages/cli/src/store/remote.mjs +++ b/packages/cli/src/store/remote.mjs @@ -189,16 +189,27 @@ class RemoteStore { // ── Memory operations → REST ────────────────────────────────────────────── - async list({ scope, tags, tags_mode, kind, host, limit, cursor, created_since, created_until, key_prefix } = {}) { + async list({ + scope, tags, tags_mode, kind, kind_mode, host, host_mode, + limit, cursor, created_since, created_until, key_prefix, + } = {}) { const p = new URLSearchParams(); if (scope) p.set('scope', scope); + // Each mode travels only with its dimension (a mode alone means nothing); + // an omitted mode is the route's own default — `any` for tags, `in` for + // kind/host. if (tags?.length) { p.set('tags', Array.isArray(tags) ? tags.join(',') : tags); - // Omitted → the route's own default (`any`). if (tags_mode) p.set('tags_mode', tags_mode); } - if (kind) p.set('kind', Array.isArray(kind) ? kind.join(',') : kind); - if (host) p.set('host', Array.isArray(host) ? host.join(',') : host); + if (kind?.length) { + p.set('kind', Array.isArray(kind) ? kind.join(',') : kind); + if (kind_mode) p.set('kind_mode', kind_mode); + } + if (host?.length) { + p.set('host', Array.isArray(host) ? host.join(',') : host); + if (host_mode) p.set('host_mode', host_mode); + } if (limit) p.set('limit', String(limit)); if (cursor) p.set('cursor', cursor); if (created_since) p.set('created_since', created_since); diff --git a/packages/cli/test/list.test.mjs b/packages/cli/test/list.test.mjs index b9ff8a6bc..945fd22ac 100644 --- a/packages/cli/test/list.test.mjs +++ b/packages/cli/test/list.test.mjs @@ -15,9 +15,9 @@ import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { scopeList, normalizeEntry, preview, shortDate, gather, matchesTags, entryFilter } from '../src/shared/lessons-view.mjs'; +import { scopeList, normalizeEntry, preview, shortDate, gather, matchesTags, matchesScalar, entryFilter } from '../src/shared/lessons-view.mjs'; import { remoteUnavailableReason } from '../src/shared/stores.mjs'; -import { parseTagsFilter } from '../src/shared/flags.mjs'; +import { parseReadFilters } from '../src/shared/flags.mjs'; import { createLocalStore } from '../src/store/local.mjs'; import { createRemoteStore } from '../src/store/remote.mjs'; @@ -113,7 +113,7 @@ test('gather captures a per-scope read failure instead of throwing', async () => assert.equal(groups[0].error, 'fetch failed'); }); -// ── unit: the --tags filter ─────────────────────────────────────────────────── +// ── unit: the --tags / --kind / --host filters ──────────────────────────────── test('matchesTags applies the hosted any/all/none semantics', () => { const tags = ['perf', 'ci']; @@ -130,6 +130,20 @@ test('matchesTags applies the hosted any/all/none semantics', () => { assert.equal(matchesTags(undefined, ['perf'], 'any'), false); }); +test('matchesScalar applies the hosted in/nin rule, excluding unset values from both', () => { + const wanted = new Set(['bus', 'signal']); + assert.equal(matchesScalar('bus', wanted, 'in'), true); + assert.equal(matchesScalar('lesson', wanted, 'in'), false); + assert.equal(matchesScalar('lesson', wanted, 'nin'), true); + assert.equal(matchesScalar('bus', wanted, 'nin'), false); + // `lorekit_match_text` (migration 00066): a row with no value matches neither. + assert.equal(matchesScalar(null, wanted, 'in'), false); + assert.equal(matchesScalar(null, wanted, 'nin'), false); + // `in` is the default; no filter matches everything, unset values included. + assert.equal(matchesScalar('bus', wanted), true); + assert.equal(matchesScalar(null, null, 'nin'), true); +}); + test('entryFilter combines kind, host and tags with AND', () => { const keep = entryFilter({ kind: 'lesson', tags: ['perf'], tags_mode: 'any' }); assert.equal(keep({ kind: 'lesson', host: null, tags: ['perf'] }), true); @@ -139,18 +153,39 @@ test('entryFilter combines kind, host and tags with AND', () => { assert.equal(entryFilter({})({ kind: null, host: null, tags: [] }), true); }); -test('parseTagsFilter defaults to any, validates the mode, and refuses a silent no-op', () => { - assert.deepEqual(parseTagsFilter({}), { filters: {} }); - assert.deepEqual(parseTagsFilter({ tags: ' perf, ci ,perf' }), { +test('entryFilter negates kind and host independently with nin', () => { + const keep = entryFilter({ kind: ['bus'], kind_mode: 'nin', host: ['ci', 'review'], host_mode: 'nin' }); + assert.equal(keep({ kind: 'lesson', host: 'reviewer', tags: [] }), true); + assert.equal(keep({ kind: 'bus', host: 'reviewer', tags: [] }), false); + assert.equal(keep({ kind: 'lesson', host: 'ci', tags: [] }), false); + assert.equal(keep({ kind: null, host: 'reviewer', tags: [] }), false); + + const mixed = entryFilter({ kind: ['lesson'], kind_mode: 'in', host: ['ci'], host_mode: 'nin' }); + assert.equal(mixed({ kind: 'lesson', host: 'aw', tags: [] }), true); + assert.equal(mixed({ kind: 'lesson', host: 'ci', tags: [] }), false); +}); + +test('parseReadFilters defaults each mode, validates it, and refuses a silent no-op', () => { + assert.deepEqual(parseReadFilters({}), { filters: {} }); + assert.deepEqual(parseReadFilters({ tags: ' perf, ci ,perf' }), { filters: { tags: ['perf', 'ci'], tags_mode: 'any' }, }); - assert.deepEqual(parseTagsFilter({ tags: 'perf', 'tags-mode': 'none' }), { + assert.deepEqual(parseReadFilters({ tags: 'perf', 'tags-mode': 'none' }), { filters: { tags: ['perf'], tags_mode: 'none' }, }); - assert.match(parseTagsFilter({ tags: 'perf', 'tags-mode': 'some' }).error, /--tags-mode must be one of any\|all\|none/); - assert.match(parseTagsFilter({ tags: true }).error, /--tags needs a comma-separated list/); - assert.match(parseTagsFilter({ tags: ' , ' }).error, /--tags needs a comma-separated list/); - assert.match(parseTagsFilter({ 'tags-mode': 'all' }).error, /--tags-mode needs --tags/); + assert.deepEqual(parseReadFilters({ kind: 'lesson,bus', host: 'ci', 'host-mode': 'nin' }), { + filters: { kind: ['lesson', 'bus'], kind_mode: 'in', host: ['ci'], host_mode: 'nin' }, + }); + assert.match(parseReadFilters({ tags: 'perf', 'tags-mode': 'some' }).error, /--tags-mode must be one of any\|all\|none/); + assert.match(parseReadFilters({ tags: true }).error, /--tags needs a comma-separated list/); + assert.match(parseReadFilters({ tags: ' , ' }).error, /--tags needs a comma-separated list/); + assert.match(parseReadFilters({ 'tags-mode': 'all' }).error, /--tags-mode needs --tags/); + // kind/host accept only in|nin — `all` is a tags-only mode. + assert.match(parseReadFilters({ kind: 'bus', 'kind-mode': 'all' }).error, /--kind-mode must be one of in\|nin/); + assert.match(parseReadFilters({ host: 'ci', 'host-mode': 'none' }).error, /--host-mode must be one of in\|nin/); + assert.match(parseReadFilters({ 'kind-mode': 'nin' }).error, /--kind-mode needs --kind/); + assert.match(parseReadFilters({ 'host-mode': 'in' }).error, /--host-mode needs --host/); + assert.match(parseReadFilters({ kind: true }).error, /--kind needs a comma-separated list/); }); test('gather narrows a local store by tags under each mode', async () => { @@ -193,6 +228,23 @@ test('RemoteStore.list forwards tags and tags_mode to GET /memories', async () = assert.equal(new URL(seen[1], 'https://x').searchParams.has('tags_mode'), false); }); +test('RemoteStore.list forwards kind_mode and host_mode only with their dimension', async () => { + const store = createRemoteStore({ endpoint: 'https://example.test/functions/v1/mcp', token: 'lk_ro_x' }); + const seen = []; + store._rest = async (p) => { seen.push(p); return { ok: true, data: { entries: [] } }; }; + + await store.list({ scope: 'global', kind: ['bus', 'signal'], kind_mode: 'nin', host: ['ci'], host_mode: 'in' }); + const params = new URL(seen[0], 'https://x').searchParams; + assert.equal(params.get('kind'), 'bus,signal'); + assert.equal(params.get('kind_mode'), 'nin'); + assert.equal(params.get('host'), 'ci'); + assert.equal(params.get('host_mode'), 'in'); + + await store.list({ scope: 'global', kind_mode: 'nin', host_mode: 'nin', kind: [] }); + const bare = new URL(seen[1], 'https://x').searchParams; + for (const key of ['kind', 'kind_mode', 'host', 'host_mode']) assert.equal(bare.has(key), false, key); +}); + // ── integration: the real binary, no remote configured ──────────────────────── // A project with an opted-in project-tier store (.lorekit/) holding two lessons. @@ -209,20 +261,22 @@ function seedProject() { fs.mkdirSync(path.join(store, 'project', projectName), { recursive: true }); fs.writeFileSync( path.join(store, 'global', 'a.md'), - entry({ scope: 'global', key: 'prefer-guard-clauses', value: 'Use early returns.', tags: ['style'] }), + entry({ scope: 'global', key: 'prefer-guard-clauses', value: 'Use early returns.', tags: ['style'], kind: 'lesson', host: 'reviewer' }), ); fs.writeFileSync( path.join(store, 'project', projectName, 'b.md'), - entry({ scope: `project::${projectName}`, key: 'widget-build-flags', value: 'Needs --no-sandbox.' }), + entry({ scope: `project::${projectName}`, key: 'widget-build-flags', value: 'Needs --no-sandbox.', kind: 'bus', host: 'ci' }), ); return { root, home }; } -function entry({ scope, key, value, tags = [] }) { +function entry({ scope, key, value, tags = [], kind, host }) { const fm = { scope, key, tags, + ...(kind ? { kind } : {}), + ...(host ? { host } : {}), source_agent: 'aw', trigger: 'manual', created: '2026-07-20T10:00:00.000Z', @@ -349,3 +403,27 @@ test('list rejects an invalid --tags-mode and a bare --tags instead of listing e assert.notEqual(bare.status, 0); assert.match(bare.stderr, /--tags needs a comma-separated list/); }); + +test('list --kind / --host narrow, and --kind-mode / --host-mode nin negate', () => { + const { root, home } = seedProject(); + const keysFor = (args) => { + const res = runList(root, home, [...args, '--json']); + assert.equal(res.status, 0, res.stderr); + return JSON.parse(res.stdout).offline.groups.flatMap((g) => g.entries.map((e) => e.key)); + }; + assert.deepEqual(keysFor(['--kind', 'bus']), ['widget-build-flags']); + assert.deepEqual(keysFor(['--kind', 'bus', '--kind-mode', 'nin']), ['prefer-guard-clauses']); + assert.deepEqual(keysFor(['--host', 'ci,reviewer', '--host-mode', 'nin']), []); + assert.deepEqual(keysFor(['--kind', 'lesson', '--host', 'ci', '--host-mode', 'nin']), ['prefer-guard-clauses']); +}); + +test('list rejects a tags-only mode on --kind-mode and a mode without its flag', () => { + const { root, home } = seedProject(); + const badMode = runList(root, home, ['--kind', 'bus', '--kind-mode', 'all']); + assert.notEqual(badMode.status, 0); + assert.match(badMode.stderr, /--kind-mode must be one of in\|nin/); + + const orphan = runList(root, home, ['--host-mode', 'nin']); + assert.notEqual(orphan.status, 0); + assert.match(orphan.stderr, /--host-mode needs --host/); +}); diff --git a/packages/cli/test/search.test.mjs b/packages/cli/test/search.test.mjs index 87ffbe2f8..efb3d69f9 100644 --- a/packages/cli/test/search.test.mjs +++ b/packages/cli/test/search.test.mjs @@ -79,11 +79,13 @@ test('filterGroups preserves a per-scope read error and never counts it', () => // ── integration fixtures ────────────────────────────────────────────────────── -function entry({ scope, key, value, tags = [] }) { +function entry({ scope, key, value, tags = [], kind, host }) { const fm = { scope, key, tags, + ...(kind ? { kind } : {}), + ...(host ? { host } : {}), source_agent: 'aw', trigger: 'manual', created: '2026-07-20T10:00:00.000Z', @@ -116,11 +118,15 @@ function seedProject() { scope: 'global', key: 'build-cache-tip', value: 'The build cache is flaky on CI.', + kind: 'bus', + host: 'ci', }); write(`project/${projectName}/c.md`, { scope: `project::${projectName}`, key: 'widget-build-flags', value: 'Needs --no-sandbox to build.', + kind: 'lesson', + host: 'reviewer', }); write(`project/${projectName}/d.md`, { scope: `project::${projectName}`, @@ -338,6 +344,28 @@ test('search --tags only matches memories carrying the tag', () => { assert.equal(JSON.parse(negated.stdout).offline.total, 2); }); +test('search --kind / --host narrow the matches, and nin negates them', () => { + const { root, home } = seedProject(); + const keysFor = (args) => { + const res = runSearch(root, home, ['build', ...args, '--json']); + assert.equal(res.status, 0, res.stderr); + return JSON.parse(res.stdout).offline.groups.flatMap((g) => g.entries.map((e) => e.key)); + }; + // `build` hits build-cache-tip (bus/ci) and widget-build-flags (lesson/reviewer). + assert.deepEqual(keysFor(['--kind', 'lesson']), ['widget-build-flags']); + assert.deepEqual(keysFor(['--kind', 'lesson', '--kind-mode', 'nin']), ['build-cache-tip']); + assert.deepEqual(keysFor(['--host', 'ci']), ['build-cache-tip']); + assert.deepEqual(keysFor(['--host', 'ci', '--host-mode', 'nin']), ['widget-build-flags']); + assert.deepEqual(keysFor(['--kind', 'bus', '--host', 'reviewer']), []); +}); + +test('search rejects an invalid --kind-mode', () => { + const { root, home } = seedProject(); + const res = runSearch(root, home, ['build', '--kind', 'bus', '--kind-mode', 'none']); + assert.notEqual(res.status, 0); + assert.match(res.stderr, /--kind-mode must be one of in\|nin/); +}); + test('search rejects an invalid --tags-mode', () => { const { root, home } = seedProject(); const res = runSearch(root, home, ['build', '--tags', 'style', '--tags-mode', 'xor']); From ebc05b5d6d9d3c0dcd966403081cbaaadca7a500 Mon Sep 17 00:00:00 2001 From: "mads.thines" Date: Tue, 20 Oct 2026 14:03:55 +0000 Subject: [PATCH 6/9] docs: document --kind/--host modes on lorekit list and search Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com> --- docs/cli.md | 21 +++++++++++++-------- packages/web/src/content/docs/tags.mdx | 5 +++++ 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 8ce0685ab..d7c0a7e72 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -96,12 +96,16 @@ without moving its version. See Alias `ls` — read-only human view of the lessons for the cwd's scopes, project/branch/repo/global, split into an **Offline** local-store section and a **Remote** hosted section that degrades -gracefully when unconfigured; `--json`/`--scope`; `--kind`/`--host` narrow by taxonomy; -`--tags a,b` narrows by tags, combined by `--tags-mode any|all|none` [default `any`, the hosted -`GET /memories` default] — forwarded to the remote AND applied to both stores by the one -`entryFilter` predicate in `lessons-view.mjs` (the pure `matchesTags` lives in `lessons-pure.mjs` -because the local store applies it too), and a bare `--tags` or a `--tags-mode` without `--tags` -is a usage error rather than an unfiltered listing; `--all` drains all pages via `gatherStream` +gracefully when unconfigured; `--json`/`--scope`; `--kind a,b`/`--host a,b` narrow by taxonomy, +each with `--kind-mode`/`--host-mode in|nin` [default `in`] — `nin` follows the hosted +`lorekit_match_text` rule (migration 00066): the value must be SET and none of the list, so an +unclassified row matches neither mode; `--tags a,b` narrows by tags, combined by +`--tags-mode any|all|none` [default `any`, the hosted `GET /memories` default]. All three are +parsed by `parseReadFilters` (`src/shared/flags.mjs`, reusing the `policy`/`groom` dimension +table), forwarded to the remote, AND applied to both stores by the one `entryFilter` predicate in +`lessons-view.mjs` (the pure `matchesTags` lives in `lessons-pure.mjs` because the local store +applies it too); a bare value flag, or a mode without its value flag, is a usage error rather than +an unfiltered listing; `--all` drains all pages via `gatherStream` [default is single-page `gather()`]; `--max ` hard-caps entries surveyed [default 5000]; `--since`/`--until ` forward `created_since`/`created_until` to the REST request; independently builds BOTH stores via `src/shared/stores.mjs` + renders via the reusable @@ -116,8 +120,9 @@ case-insensitive** substring against a lesson's key OR value, via the pure `matc dependency-free `lessons-pure.mjs`, re-exported by `lessons-view.mjs`)/`filterGroups` helpers over the shared `gather()` output — NOT each store's native `search` method, whose semantics diverge (local literal-substring vs remote server-FTS); regex metachars are matched verbatim, never -compiled; `--json`/`--scope`; `--tags a,b [--tags-mode any|all|none]` narrows the candidates -before the query is matched, exactly as on `list` +compiled; `--json`/`--scope`; `--kind`/`--host` (+ `--kind-mode`/`--host-mode in|nin`) and +`--tags` (+ `--tags-mode any|all|none`) narrow the candidates before the query is matched, exactly +as on `list` ### `show` diff --git a/packages/web/src/content/docs/tags.mdx b/packages/web/src/content/docs/tags.mdx index c2aad325f..39909bb08 100644 --- a/packages/web/src/content/docs/tags.mdx +++ b/packages/web/src/content/docs/tags.mdx @@ -185,9 +185,14 @@ npx @lorekit/cli list # Only memories carrying a tag (any of a comma list; --tags-mode all|none to change that) npx @lorekit/cli list --tags loop::aw-lessons +# One bucket family and owner, or everything except one (--kind-mode / --host-mode nin) +npx @lorekit/cli list --kind lesson --host reviewer +npx @lorekit/cli list --kind bus --kind-mode nin + # Full-text search, optionally narrowed by tag npx @lorekit/cli search "worktree" npx @lorekit/cli search "worktree" --tags skill::aw +npx @lorekit/cli search "worktree" --kind lesson --host-mode nin --host ci # Inspect one memory in full npx @lorekit/cli show --scope global --key aw-lessons::worktree-naming From a0b612a6607e6a602f4223ebabcd20a58b8855a3 Mon Sep 17 00:00:00 2001 From: "mads.thines" Date: Tue, 20 Oct 2026 15:09:39 +0000 Subject: [PATCH 7/9] feat(cli): give list and search the trigger, source-agent and origin-* filters parseReadFilters now covers all eight policy/groom dimensions, each with an in|nin mode. --origin-pr accepts PR numbers only (a leading # is stripped, 007 reads as 7): the hosted route drops a non-numeric value and then applies no PR filter. normalizeEntry carries source_agent, trigger and origin_* so the shared entryFilter can match them on both stores and in list --all; RemoteStore.list forwards every dimension. Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com> --- packages/cli/bin/lorekit.mjs | 21 +++-- packages/cli/src/commands/list.mjs | 13 +-- packages/cli/src/commands/search.mjs | 15 ++-- packages/cli/src/shared/completions.mjs | 23 +++-- packages/cli/src/shared/flags.mjs | 70 ++++++++++----- packages/cli/src/shared/lessons-view.mjs | 42 +++++++-- packages/cli/src/store/remote.mjs | 36 ++++---- packages/cli/test/list.test.mjs | 107 ++++++++++++++++++++++- packages/cli/test/search.test.mjs | 22 ++++- 9 files changed, 271 insertions(+), 78 deletions(-) diff --git a/packages/cli/bin/lorekit.mjs b/packages/cli/bin/lorekit.mjs index 20053aa54..ea0f75d05 100755 --- a/packages/cli/bin/lorekit.mjs +++ b/packages/cli/bin/lorekit.mjs @@ -350,12 +350,13 @@ short note on how to set it up — never an error. ${c.bold('Options')} -d, --dir Target project root (default: current directory) --scope Restrict to a single scope (default: all applicable) - --kind Only memories of these kinds (lesson, bus, signal) - --kind-mode in (is one of them, default) | nin (is none of them) - --host Only memories owned by these hosts (e.g. reviewer, aw) - --host-mode in (is one of them, default) | nin (is none of them) --tags Only memories carrying these tags (combined by --tags-mode) --tags-mode any (at least one, default) | all (every one) | none + --kind, --host, --trigger, --source-agent, + --origin-repo, --origin-branch, --origin-pr + Only memories whose field is one of these values + (kind: lesson|bus|signal; origin-pr: PR numbers) + ---mode in (is one of them, default) | nin (is set, and none of them) --json Machine-readable output -e, --endpoint Remote endpoint override (else .mcp.json / LOREKIT_MCP_URL) -t, --token Remote token override (else .mcp.json / LOREKIT_TOKEN) @@ -370,6 +371,8 @@ ${c.bold('Examples')} npx @lorekit/cli list --tags perf,ci --tags-mode all npx @lorekit/cli list --kind lesson --host reviewer npx @lorekit/cli list --kind bus --kind-mode nin + npx @lorekit/cli list --source-agent claude --origin-pr 482 + npx @lorekit/cli list --origin-branch main --origin-branch-mode nin npx @lorekit/cli list --scope global --link `, search: `${c.bold('lorekit search')} — full-text search the applicable memories ${c.dim('(alias: grep)')} @@ -386,12 +389,13 @@ never interpreted). Results are shown in the same Offline / Remote split as ${c.bold('Options')} -d, --dir Target project root (default: current directory) --scope Restrict to a single scope (default: all applicable) - --kind Only search memories of these kinds (lesson, bus, signal) - --kind-mode in (is one of them, default) | nin (is none of them) - --host Only search memories owned by these hosts (e.g. reviewer, aw) - --host-mode in (is one of them, default) | nin (is none of them) --tags Only search memories carrying these tags (combined by --tags-mode) --tags-mode any (at least one, default) | all (every one) | none + --kind, --host, --trigger, --source-agent, + --origin-repo, --origin-branch, --origin-pr + Only search memories whose field is one of these values + (kind: lesson|bus|signal; origin-pr: PR numbers) + ---mode in (is one of them, default) | nin (is set, and none of them) --json Machine-readable output -e, --endpoint Remote endpoint override (else .mcp.json / LOREKIT_MCP_URL) -t, --token Remote token override (else .mcp.json / LOREKIT_TOKEN) @@ -404,6 +408,7 @@ ${c.bold('Examples')} npx @lorekit/cli search migration --scope global npx @lorekit/cli search migration --tags loop::aw-lessons npx @lorekit/cli search flaky --kind lesson --host-mode nin --host ci + npx @lorekit/cli search flaky --trigger stuck-loop --origin-repo mthines/lorekit npx @lorekit/cli search "flaky test" --scope global --link `, write: `${c.bold('lorekit write')} — create or update a memory from the CLI diff --git a/packages/cli/src/commands/list.mjs b/packages/cli/src/commands/list.mjs index 08fda4640..7b8e29a10 100644 --- a/packages/cli/src/commands/list.mjs +++ b/packages/cli/src/commands/list.mjs @@ -42,9 +42,10 @@ export async function list(args) { // Default to every applicable scope; `--scope ` narrows to one (an explicit // scope outside the applicable set is honoured — the user asked for it). const scopes = args.scope && typeof args.scope === 'string' ? [args.scope] : scopeList(scopeInfo); - // Optional dimension filters, ANDed — `--kind lesson --host reviewer` narrows - // to one family/owner, `--kind-mode nin` / `--host-mode nin` negate, and - // `--tags a,b [--tags-mode any|all|none]` matches `memory.list`'s semantics. + // Optional dimension filters, ANDed — the eight `policy`/`groom` take: + // `--tags a,b [--tags-mode any|all|none]` (`memory.list`'s semantics), and + // `--kind`/`--host`/`--trigger`/`--source-agent`/`--origin-repo`/ + // `--origin-branch`/`--origin-pr a,b`, each negated by `---mode nin`. const parsed = parseReadFilters(args); if (parsed.error) { err(`${c.red('Error:')} ${parsed.error}`); @@ -156,9 +157,9 @@ export async function list(args) { 'lorekit.cli.list.offline_count': offline.total, 'lorekit.cli.list.remote_count': remoteResult.total, 'lorekit.cli.list.remote_available': remoteAvailable, - 'lorekit.cli.list.tags_filtered': Boolean(filters.tags), - 'lorekit.cli.list.kind_filtered': Boolean(filters.kind), - 'lorekit.cli.list.host_filtered': Boolean(filters.host), + // How many of the eight dimension filters were applied (0–8) — a count, + // never a value. + 'lorekit.cli.list.filter_count': Object.keys(filters).filter((k) => !k.endsWith('_mode')).length, }; } diff --git a/packages/cli/src/commands/search.mjs b/packages/cli/src/commands/search.mjs index e9d933394..c1628326f 100644 --- a/packages/cli/src/commands/search.mjs +++ b/packages/cli/src/commands/search.mjs @@ -34,7 +34,7 @@ export async function search(args) { // and would otherwise degrade to `list`. const query = typeof args._[1] === 'string' ? args._[1] : ''; if (!query.trim()) { - err(`${c.red('Usage:')} lorekit search [--scope ] [--kind|--host [--kind-mode|--host-mode in|nin]] [--tags [--tags-mode any|all|none]] [--json]`); + err(`${c.red('Usage:')} lorekit search [--scope ] [--tags [--tags-mode any|all|none]] [--kind|--host|--trigger|--source-agent|--origin-repo|--origin-branch|--origin-pr [---mode in|nin]] [--json]`); err(`Provide a search term. Run ${c.cyan('lorekit search --help')} for options.`); return 1; } @@ -44,9 +44,10 @@ export async function search(args) { // scope outside the applicable set is honoured — the user asked for it). const scopes = args.scope && typeof args.scope === 'string' ? [args.scope] : scopeList(scopeInfo); - // `--kind`/`--host` (+ `--kind-mode`/`--host-mode in|nin`) and `--tags` - // (+ `--tags-mode any|all|none`) narrow the candidate set before the query is - // matched — the same filters, with the same semantics, as `list`. + // The eight dimension filters (`--tags` + `--tags-mode any|all|none`, and + // `--kind`/`--host`/`--trigger`/`--source-agent`/`--origin-*` + `---mode + // in|nin`) narrow the candidate set before the query is matched — the same + // filters, with the same semantics, as `list`. const parsed = parseReadFilters(args); if (parsed.error) { err(`${c.red('Error:')} ${parsed.error}`); @@ -119,9 +120,9 @@ export async function search(args) { 'lorekit.cli.search.offline_matches': offline.total, 'lorekit.cli.search.remote_matches': remoteResult.total, 'lorekit.cli.search.remote_available': remoteAvailable, - 'lorekit.cli.search.tags_filtered': Boolean(filters.tags), - 'lorekit.cli.search.kind_filtered': Boolean(filters.kind), - 'lorekit.cli.search.host_filtered': Boolean(filters.host), + // How many of the eight dimension filters were applied (0–8) — a count, + // never a value. + 'lorekit.cli.search.filter_count': Object.keys(filters).filter((k) => !k.endsWith('_mode')).length, }; } diff --git a/packages/cli/src/shared/completions.mjs b/packages/cli/src/shared/completions.mjs index 19e2ca88f..bcb7c5d92 100644 --- a/packages/cli/src/shared/completions.mjs +++ b/packages/cli/src/shared/completions.mjs @@ -56,16 +56,21 @@ const FLAG = { 'kind-mode': { desc: 'How --kind matches', arg: 'mode', values: ['in', 'nin'] }, host: { desc: 'Owning host filter', arg: 'a,b' }, 'host-mode': { desc: 'How --host matches', arg: 'mode', values: ['in', 'nin'] }, - 'source-agent': { desc: 'Source agent name to record', arg: 'name' }, + 'source-agent': { desc: 'Source agent name', arg: 'name' }, + 'source-agent-mode': { desc: 'How --source-agent matches', arg: 'mode', values: ['in', 'nin'] }, trigger: { desc: 'Trigger context slug', arg: 'slug' }, + 'trigger-mode': { desc: 'How --trigger matches', arg: 'mode', values: ['in', 'nin'] }, 'ttl-days': { desc: 'Days until auto-expiry (1..365)', arg: 'n' }, 'clear-ttl': { desc: 'Remove any existing expiry' }, org: { desc: "Write to this org's scope (remote)", arg: 'slug' }, cited: { desc: 'scope::key refs this lesson applied (remote)', arg: 'a,b' }, - 'origin-repo': { desc: 'Override the provenance repository', arg: 'owner/name' }, - 'origin-branch': { desc: 'Override the provenance branch', arg: 'branch' }, + 'origin-repo': { desc: 'Provenance repository', arg: 'owner/name' }, + 'origin-repo-mode': { desc: 'How --origin-repo matches', arg: 'mode', values: ['in', 'nin'] }, + 'origin-branch': { desc: 'Provenance branch', arg: 'branch' }, + 'origin-branch-mode': { desc: 'How --origin-branch matches', arg: 'mode', values: ['in', 'nin'] }, 'origin-commit': { desc: 'Override the provenance commit', arg: 'sha' }, - 'origin-pr': { desc: 'The pull request this came out of', arg: 'n' }, + 'origin-pr': { desc: 'Pull request number(s)', arg: 'n' }, + 'origin-pr-mode': { desc: 'How --origin-pr matches', arg: 'mode', values: ['in', 'nin'] }, 'no-origin': { desc: 'Record no provenance at all' }, remote: { desc: 'Force the remote store' }, local: { desc: 'Force the local offline store' }, @@ -116,10 +121,16 @@ const COMMANDS = [ { name: 'update', summary: 'Offline refresh of the bundled skills to the shipped version', flags: ['dir', 'project', 'global', 'check'] }, { name: 'list', summary: 'List memories for the current directory', aliases: ['ls'], - flags: ['dir', 'scope', 'kind', 'kind-mode', 'host', 'host-mode', 'tags', 'tags-mode', 'json', 'endpoint', 'token', 'store', 'link', 'base'] }, + flags: ['dir', 'scope', 'tags', 'tags-mode', 'kind', 'kind-mode', 'host', 'host-mode', + 'trigger', 'trigger-mode', 'source-agent', 'source-agent-mode', 'origin-repo', 'origin-repo-mode', + 'origin-branch', 'origin-branch-mode', 'origin-pr', 'origin-pr-mode', + 'json', 'endpoint', 'token', 'store', 'link', 'base'] }, { name: 'search', summary: 'Full-text search the applicable memories', aliases: ['grep'], positional: 'query', - flags: ['dir', 'scope', 'kind', 'kind-mode', 'host', 'host-mode', 'tags', 'tags-mode', 'json', 'endpoint', 'token', 'store', 'link', 'base'] }, + flags: ['dir', 'scope', 'tags', 'tags-mode', 'kind', 'kind-mode', 'host', 'host-mode', + 'trigger', 'trigger-mode', 'source-agent', 'source-agent-mode', 'origin-repo', 'origin-repo-mode', + 'origin-branch', 'origin-branch-mode', 'origin-pr', 'origin-pr-mode', + 'json', 'endpoint', 'token', 'store', 'link', 'base'] }, { name: 'show', summary: 'Inspect one memory in full', positional: 'address', flags: ['dir', 'json', 'endpoint', 'token', 'store', 'scope', 'key', 'link', 'base'] }, { name: 'stats', summary: 'Count memories per scope and store', diff --git a/packages/cli/src/shared/flags.mjs b/packages/cli/src/shared/flags.mjs index 8162b7639..a8f64b82c 100644 --- a/packages/cli/src/shared/flags.mjs +++ b/packages/cli/src/shared/flags.mjs @@ -89,37 +89,56 @@ export function parseDimensionConditions(args, { clearable = false } = {}) { } /** - * The dimensions the read commands (`list`, `search`) filter on, in the order - * they are checked, with an example value list for the usage error. Each - * reuses its {@link DIMENSIONS} row for the field name and the valid modes, so - * `--kind-mode` accepts exactly what `policy create --kind-mode` accepts. + * An example value list per dimension, for the read commands' usage error. + * The read commands accept every {@link DIMENSIONS} row — the same eight + * filters `policy create` and `groom` take, with the same field names and the + * same valid modes, so `--trigger-mode` accepts exactly what + * `policy create --trigger-mode` accepts. */ -const READ_FILTERS = [ - { flag: 'tags', example: 'loop::aw-lessons,perf' }, - { flag: 'kind', example: 'lesson,bus' }, - { flag: 'host', example: 'reviewer,aw' }, -]; +const READ_FILTER_EXAMPLES = { + tags: 'loop::aw-lessons,perf', + 'source-agent': 'claude,aw', + trigger: 'stuck-loop,manual', + kind: 'lesson,bus', + host: 'reviewer,aw', + 'origin-repo': 'mthines/lorekit', + 'origin-branch': 'main,feat/x', + 'origin-pr': '482,511', +}; + +/** + * One `--origin-pr` value as the canonical digits the hosted route compares, + * or `null` when it is not a pull request number. A leading `#` is accepted — + * the dashboard writes PRs as `#482` — and `007` becomes `7`, because the + * hosted filter compares numerically (`lorekit_match_int`, migration 00066). + */ +export function canonicalPrNumber(raw) { + const digits = String(raw).trim().replace(/^#/, ''); + if (!/^\d+$/.test(digits)) return null; + return String(Number(digits)); +} /** * Parse the read commands' dimension filters — `--tags a,b [--tags-mode - * any|all|none]`, `--kind a,b [--kind-mode in|nin]` and `--host a,b - * [--host-mode in|nin]` on `list` and `search`. + * any|all|none]`, and `--kind`, `--host`, `--trigger`, `--source-agent`, + * `--origin-repo`, `--origin-branch`, `--origin-pr a,b`, each with a + * `---mode in|nin` — on `list` and `search`. * * Returns `{ filters }` holding only the dimensions that were given, each as a * value array plus its `*_mode` — defaulting to the dimension's first mode - * (`any` for tags, `in` for kind/host), the hosted list's own defaults, so + * (`any` for tags, `in` for the rest), the hosted list's own defaults, so * offline and remote agree — or `{ error }` naming the offending flag. * * Stricter than {@link parseDimensionConditions} on purpose: a bare `--tags` - * with no value, or a `--kind-mode` with no `--kind`, is rejected rather than - * quietly listing everything. That silent no-op is how `list --tags x` used to - * behave (`tags` was a known flag for `write`, so it parsed cleanly and - * filtered nothing). + * with no value, a `--kind-mode` with no `--kind`, or an `--origin-pr` that is + * not a number is rejected rather than quietly listing everything. Each of + * those would otherwise filter nothing: `list --tags x` used to parse cleanly + * (a known flag for `write`) and be ignored, and the hosted route DROPS a + * non-numeric PR value, then applies no PR filter once the list is empty. */ export function parseReadFilters(args) { const filters = {}; - for (const { flag, example } of READ_FILTERS) { - const { field, modes } = DIMENSIONS.find((d) => d.flag === flag); + for (const { flag, field, modes } of DIMENSIONS) { const raw = args[flag]; const modeRaw = args[`${flag}-mode`]; @@ -131,13 +150,20 @@ export function parseReadFilters(args) { continue; } - const values = typeof raw === 'string' - ? [...new Set(raw.split(',').map((s) => s.trim()).filter((s) => s.length > 0))] + let values = typeof raw === 'string' + ? raw.split(',').map((s) => s.trim()).filter((s) => s.length > 0) : []; if (values.length === 0) { - return { error: `--${flag} needs a comma-separated list, e.g. --${flag} ${example}` }; + return { error: `--${flag} needs a comma-separated list, e.g. --${flag} ${READ_FILTER_EXAMPLES[flag]}` }; + } + if (field === 'origin_pr') { + const bad = values.find((v) => canonicalPrNumber(v) === null); + if (bad !== undefined) { + return { error: `--origin-pr takes pull request numbers, e.g. --origin-pr 482,511 — got ${JSON.stringify(bad)}` }; + } + values = values.map(canonicalPrNumber); } - filters[field] = values; + filters[field] = [...new Set(values)]; filters[`${field}_mode`] = modeRaw ?? modes[0]; } return { filters }; diff --git a/packages/cli/src/shared/lessons-view.mjs b/packages/cli/src/shared/lessons-view.mjs index cc1c98ade..42597dcd0 100644 --- a/packages/cli/src/shared/lessons-view.mjs +++ b/packages/cli/src/shared/lessons-view.mjs @@ -8,6 +8,7 @@ // Kept separate from `list.mjs` on purpose: the offline/remote sectioned // rendering is a reusable seam, not a `list`-only detail. Zero-dependency. import { log, heading, status, c } from './util.mjs'; +import { canonicalPrNumber } from './flags.mjs'; // `resolvePrecedence` (the cross-scope merge) and `matchesQuery` (the literal // substring matcher) live in the dependency-free `lessons-pure.mjs` so the hook // hot path can share them without pulling in this file's `util`/render stack. @@ -84,6 +85,15 @@ export function normalizeEntry(e = {}) { tags, kind: e.kind ?? inferred.kind ?? null, host: e.host ?? inferred.host ?? null, + // Attribution and provenance — carried so `list`/`search` can filter on them + // (`--source-agent`, `--trigger`, `--origin-*`) after normalization, which + // both `gather()` and `gatherStream` apply before any filter runs. Both + // stores already hold these columns under the same names. + source_agent: e.source_agent ?? null, + trigger: e.trigger ?? null, + origin_repo: e.origin_repo ?? null, + origin_branch: e.origin_branch ?? null, + origin_pr: e.origin_pr ?? null, }; } @@ -611,21 +621,39 @@ export function matchesScalar(value, wanted, mode = 'in') { return mode === 'nin' ? !wanted.has(value) : wanted.has(value); } -// The `keep` predicate for normalized entries under the read commands' filters -// (`kind` + `kind_mode`, `host` + `host_mode`, `tags` + `tags_mode`), ANDed. +// The single-valued dimensions the read commands filter on, by normalized field +// name — the same names the hosted `GET /memories` params use. +export const SCALAR_FILTER_FIELDS = Object.freeze([ + 'kind', 'host', 'source_agent', 'trigger', 'origin_repo', 'origin_branch', 'origin_pr', +]); + +// A PR number as the digits the hosted filter compares numerically, so `7`, +// `'7'` and `'007'` are one value — the remote column is an integer, a local +// row may hold either type. A value that is not a number is kept verbatim (it +// can then only match itself), never coerced into a different PR. +function canonicalPr(v) { + if (v == null) return null; + return canonicalPrNumber(v) ?? String(v); +} + +// The `keep` predicate for normalized entries under the read commands' filters, +// ANDed: `tags` + `tags_mode`, and each of SCALAR_FILTER_FIELDS + its `*_mode`. // ONE predicate for every path that narrows a listing — `gather()`, and // `list --all`'s streamed drain — so the offline store, the remote store and a // full drain can never disagree about what a filter means. Kind/host match the // taxonomy `normalizeEntry` inferred for a row without explicit columns. Pure. export function entryFilter(filters = {}) { - const kindSet = valueSet(filters.kind); - const hostSet = valueSet(filters.host); + const scalars = SCALAR_FILTER_FIELDS.map((field) => { + const raw = valueSet(filters[field]); + const wanted = raw && field === 'origin_pr' ? new Set([...raw].map(canonicalPr)) : raw; + return { field, wanted, mode: filters[`${field}_mode`] }; + }).filter((s) => s.wanted); const tagSet = valueSet(filters.tags); const tagsMode = filters.tags_mode ?? DEFAULT_TAGS_MODE; return (e) => - matchesScalar(e.kind, kindSet, filters.kind_mode) && - matchesScalar(e.host, hostSet, filters.host_mode) && - matchesTags(e.tags, tagSet, tagsMode); + scalars.every(({ field, wanted, mode }) => + matchesScalar(field === 'origin_pr' ? canonicalPr(e[field]) : e[field], wanted, mode), + ) && matchesTags(e.tags, tagSet, tagsMode); } // Collect a store's non-archived entries for each scope, via the common diff --git a/packages/cli/src/store/remote.mjs b/packages/cli/src/store/remote.mjs index 1b69e359a..6733362ff 100644 --- a/packages/cli/src/store/remote.mjs +++ b/packages/cli/src/store/remote.mjs @@ -140,6 +140,13 @@ function remoteTtlDaysExact(expiresAt, now = new Date()) { return Math.ceil(remaining / 86_400_000); } +// The `GET /memories` dimension params `list()` forwards — the eight filters +// the Explorer bar and retention policies share, minus `owner` (the CLI has no +// owner flag). Each may carry a matching `_mode`. +const LIST_DIMENSION_PARAMS = [ + 'tags', 'kind', 'host', 'source_agent', 'trigger', 'origin_repo', 'origin_branch', 'origin_pr', +]; + export function createRemoteStore({ endpoint, token } = {}) { return new RemoteStore(endpoint, token); } @@ -189,26 +196,19 @@ class RemoteStore { // ── Memory operations → REST ────────────────────────────────────────────── - async list({ - scope, tags, tags_mode, kind, kind_mode, host, host_mode, - limit, cursor, created_since, created_until, key_prefix, - } = {}) { + async list({ scope, limit, cursor, created_since, created_until, key_prefix, ...dimensions } = {}) { const p = new URLSearchParams(); if (scope) p.set('scope', scope); - // Each mode travels only with its dimension (a mode alone means nothing); - // an omitted mode is the route's own default — `any` for tags, `in` for - // kind/host. - if (tags?.length) { - p.set('tags', Array.isArray(tags) ? tags.join(',') : tags); - if (tags_mode) p.set('tags_mode', tags_mode); - } - if (kind?.length) { - p.set('kind', Array.isArray(kind) ? kind.join(',') : kind); - if (kind_mode) p.set('kind_mode', kind_mode); - } - if (host?.length) { - p.set('host', Array.isArray(host) ? host.join(',') : host); - if (host_mode) p.set('host_mode', host_mode); + // The dimension filters, under the route's own param names. Each mode + // travels only with its dimension (a mode alone means nothing); an omitted + // mode is the route's default — `any` for tags, `in` for the rest. Any + // other key a caller passes is ignored, exactly as before. + for (const field of LIST_DIMENSION_PARAMS) { + const values = dimensions[field]; + if (!values?.length) continue; + p.set(field, Array.isArray(values) ? values.join(',') : values); + const mode = dimensions[`${field}_mode`]; + if (mode) p.set(`${field}_mode`, mode); } if (limit) p.set('limit', String(limit)); if (cursor) p.set('cursor', cursor); diff --git a/packages/cli/test/list.test.mjs b/packages/cli/test/list.test.mjs index 945fd22ac..42bce38fc 100644 --- a/packages/cli/test/list.test.mjs +++ b/packages/cli/test/list.test.mjs @@ -59,6 +59,11 @@ test('normalizeEntry maps updated_at → updated and coerces value/tags', () => tags: [], kind: 'lesson', host: 'reviewer', + source_agent: null, + trigger: null, + origin_repo: null, + origin_branch: null, + origin_pr: null, }); // A row without taxonomy columns normalizes them to null. assert.deepEqual( @@ -165,6 +170,20 @@ test('entryFilter negates kind and host independently with nin', () => { assert.equal(mixed({ kind: 'lesson', host: 'ci', tags: [] }), false); }); +test('entryFilter applies source_agent, trigger and origin_* with the same in/nin rule', () => { + const row = { tags: [], source_agent: 'claude', trigger: 'stuck-loop', origin_repo: 'acme/widget', origin_branch: 'main', origin_pr: 7 }; + assert.equal(entryFilter({ source_agent: ['claude'] })(row), true); + assert.equal(entryFilter({ source_agent: ['claude'], source_agent_mode: 'nin' })(row), false); + assert.equal(entryFilter({ trigger: ['manual'], trigger_mode: 'nin' })(row), true); + assert.equal(entryFilter({ origin_repo: ['acme/widget'], origin_branch: ['feat/x'] })(row), false); + // origin_pr compares numerically, whatever type either side holds. + assert.equal(entryFilter({ origin_pr: ['007'] })(row), true); + assert.equal(entryFilter({ origin_pr: ['7'] })({ ...row, origin_pr: '7' }), true); + assert.equal(entryFilter({ origin_pr: ['8'], origin_pr_mode: 'nin' })(row), true); + // A row with no provenance matches neither mode, as on the server. + assert.equal(entryFilter({ origin_pr: ['8'], origin_pr_mode: 'nin' })({ ...row, origin_pr: null }), false); +}); + test('parseReadFilters defaults each mode, validates it, and refuses a silent no-op', () => { assert.deepEqual(parseReadFilters({}), { filters: {} }); assert.deepEqual(parseReadFilters({ tags: ' perf, ci ,perf' }), { @@ -188,6 +207,33 @@ test('parseReadFilters defaults each mode, validates it, and refuses a silent no assert.match(parseReadFilters({ kind: true }).error, /--kind needs a comma-separated list/); }); +test('parseReadFilters covers the provenance and attribution dimensions policy takes', () => { + assert.deepEqual( + parseReadFilters({ + 'source-agent': 'claude', 'source-agent-mode': 'nin', + trigger: 'stuck-loop,manual', + 'origin-repo': 'mthines/lorekit', + 'origin-branch': 'main', 'origin-branch-mode': 'nin', + 'origin-pr': '#482, 007,482', + }), + { + filters: { + source_agent: ['claude'], source_agent_mode: 'nin', + trigger: ['stuck-loop', 'manual'], trigger_mode: 'in', + origin_repo: ['mthines/lorekit'], origin_repo_mode: 'in', + origin_branch: ['main'], origin_branch_mode: 'nin', + // `#` stripped, `007` read as 7, the repeat de-duplicated. + origin_pr: ['482', '7'], origin_pr_mode: 'in', + }, + }, + ); + // A non-numeric PR would be DROPPED by the hosted route, which then applies no + // PR filter at all — so it is refused here instead of listing everything. + assert.match(parseReadFilters({ 'origin-pr': '482,abc' }).error, /--origin-pr takes pull request numbers.*"abc"/); + assert.match(parseReadFilters({ 'trigger-mode': 'nin' }).error, /--trigger-mode needs --trigger/); + assert.match(parseReadFilters({ 'origin-repo': 'a/b', 'origin-repo-mode': 'any' }).error, /--origin-repo-mode must be one of in\|nin/); +}); + test('gather narrows a local store by tags under each mode', async () => { const dir = tmp('lk-list-tags-'); const store = createLocalStore(dir); @@ -245,6 +291,31 @@ test('RemoteStore.list forwards kind_mode and host_mode only with their dimensio for (const key of ['kind', 'kind_mode', 'host', 'host_mode']) assert.equal(bare.has(key), false, key); }); +test('RemoteStore.list forwards the attribution and provenance dimensions', async () => { + const store = createRemoteStore({ endpoint: 'https://example.test/functions/v1/mcp', token: 'lk_ro_x' }); + const seen = []; + store._rest = async (p) => { seen.push(p); return { ok: true, data: { entries: [] } }; }; + + await store.list({ + scope: 'global', + source_agent: ['claude', 'aw'], source_agent_mode: 'nin', + trigger: ['stuck-loop'], + origin_repo: ['mthines/lorekit'], + origin_branch: ['main'], origin_branch_mode: 'nin', + origin_pr: ['482'], origin_pr_mode: 'in', + owner: ['personal'], // not a list() dimension — must not leak onto the wire + }); + const params = new URL(seen[0], 'https://x').searchParams; + assert.equal(params.get('source_agent'), 'claude,aw'); + assert.equal(params.get('source_agent_mode'), 'nin'); + assert.equal(params.get('trigger'), 'stuck-loop'); + assert.equal(params.has('trigger_mode'), false); + assert.equal(params.get('origin_repo'), 'mthines/lorekit'); + assert.equal(params.get('origin_branch_mode'), 'nin'); + assert.equal(params.get('origin_pr'), '482'); + assert.equal(params.has('owner'), false); +}); + // ── integration: the real binary, no remote configured ──────────────────────── // A project with an opted-in project-tier store (.lorekit/) holding two lessons. @@ -261,16 +332,22 @@ function seedProject() { fs.mkdirSync(path.join(store, 'project', projectName), { recursive: true }); fs.writeFileSync( path.join(store, 'global', 'a.md'), - entry({ scope: 'global', key: 'prefer-guard-clauses', value: 'Use early returns.', tags: ['style'], kind: 'lesson', host: 'reviewer' }), + entry({ + scope: 'global', key: 'prefer-guard-clauses', value: 'Use early returns.', tags: ['style'], + kind: 'lesson', host: 'reviewer', source_agent: 'claude', origin_repo: 'acme/widget', origin_pr: 482, + }), ); fs.writeFileSync( path.join(store, 'project', projectName, 'b.md'), - entry({ scope: `project::${projectName}`, key: 'widget-build-flags', value: 'Needs --no-sandbox.', kind: 'bus', host: 'ci' }), + entry({ + scope: `project::${projectName}`, key: 'widget-build-flags', value: 'Needs --no-sandbox.', + kind: 'bus', host: 'ci', trigger: 'stuck-loop', origin_branch: 'main', + }), ); return { root, home }; } -function entry({ scope, key, value, tags = [], kind, host }) { +function entry({ scope, key, value, tags = [], kind, host, ...overrides }) { const fm = { scope, key, @@ -282,6 +359,7 @@ function entry({ scope, key, value, tags = [], kind, host }) { created: '2026-07-20T10:00:00.000Z', updated: '2026-07-20T10:00:00.000Z', archived_at: null, + ...overrides, }; const lines = Object.entries(fm).map(([k, v]) => `${k}: ${JSON.stringify(v)}`); return `---\n${lines.join('\n')}\n---\n${value}\n`; @@ -427,3 +505,26 @@ test('list rejects a tags-only mode on --kind-mode and a mode without its flag', assert.notEqual(orphan.status, 0); assert.match(orphan.stderr, /--host-mode needs --host/); }); + +test('list filters by source agent, trigger and provenance, with nin', () => { + const { root, home } = seedProject(); + const keysFor = (args) => { + const res = runList(root, home, [...args, '--json']); + assert.equal(res.status, 0, res.stderr); + return JSON.parse(res.stdout).offline.groups.flatMap((g) => g.entries.map((e) => e.key)); + }; + assert.deepEqual(keysFor(['--source-agent', 'claude']), ['prefer-guard-clauses']); + assert.deepEqual(keysFor(['--source-agent', 'claude', '--source-agent-mode', 'nin']), ['widget-build-flags']); + assert.deepEqual(keysFor(['--trigger', 'stuck-loop']), ['widget-build-flags']); + assert.deepEqual(keysFor(['--origin-repo', 'acme/widget']), ['prefer-guard-clauses']); + assert.deepEqual(keysFor(['--origin-pr', '#482']), ['prefer-guard-clauses']); + // `nin` needs a set value: the row with no branch matches neither mode. + assert.deepEqual(keysFor(['--origin-branch', 'feat/x', '--origin-branch-mode', 'nin']), ['widget-build-flags']); +}); + +test('list refuses a non-numeric --origin-pr instead of listing everything', () => { + const { root, home } = seedProject(); + const res = runList(root, home, ['--origin-pr', 'abc']); + assert.notEqual(res.status, 0); + assert.match(res.stderr, /--origin-pr takes pull request numbers/); +}); diff --git a/packages/cli/test/search.test.mjs b/packages/cli/test/search.test.mjs index efb3d69f9..b68c4c78e 100644 --- a/packages/cli/test/search.test.mjs +++ b/packages/cli/test/search.test.mjs @@ -79,7 +79,7 @@ test('filterGroups preserves a per-scope read error and never counts it', () => // ── integration fixtures ────────────────────────────────────────────────────── -function entry({ scope, key, value, tags = [], kind, host }) { +function entry({ scope, key, value, tags = [], kind, host, ...overrides }) { const fm = { scope, key, @@ -91,6 +91,7 @@ function entry({ scope, key, value, tags = [], kind, host }) { created: '2026-07-20T10:00:00.000Z', updated: '2026-07-20T10:00:00.000Z', archived_at: null, + ...overrides, }; const lines = Object.entries(fm).map(([k, v]) => `${k}: ${JSON.stringify(v)}`); return `---\n${lines.join('\n')}\n---\n${value}\n`; @@ -120,6 +121,7 @@ function seedProject() { value: 'The build cache is flaky on CI.', kind: 'bus', host: 'ci', + trigger: 'stuck-loop', }); write(`project/${projectName}/c.md`, { scope: `project::${projectName}`, @@ -127,6 +129,8 @@ function seedProject() { value: 'Needs --no-sandbox to build.', kind: 'lesson', host: 'reviewer', + origin_repo: 'acme/widget', + origin_pr: 7, }); write(`project/${projectName}/d.md`, { scope: `project::${projectName}`, @@ -359,6 +363,22 @@ test('search --kind / --host narrow the matches, and nin negates them', () => { assert.deepEqual(keysFor(['--kind', 'bus', '--host', 'reviewer']), []); }); +test('search filters by trigger and provenance, with nin', () => { + const { root, home } = seedProject(); + const keysFor = (args) => { + const res = runSearch(root, home, ['build', ...args, '--json']); + assert.equal(res.status, 0, res.stderr); + return JSON.parse(res.stdout).offline.groups.flatMap((g) => g.entries.map((e) => e.key)); + }; + // `build` hits build-cache-tip (trigger stuck-loop, no provenance) and + // widget-build-flags (trigger manual, acme/widget PR 7). + assert.deepEqual(keysFor(['--trigger', 'stuck-loop']), ['build-cache-tip']); + assert.deepEqual(keysFor(['--trigger', 'stuck-loop', '--trigger-mode', 'nin']), ['widget-build-flags']); + assert.deepEqual(keysFor(['--origin-pr', '007']), ['widget-build-flags']); + assert.deepEqual(keysFor(['--origin-repo', 'acme/widget', '--origin-repo-mode', 'nin']), []); + assert.deepEqual(keysFor(['--source-agent', 'aw']).sort(), ['build-cache-tip', 'widget-build-flags']); +}); + test('search rejects an invalid --kind-mode', () => { const { root, home } = seedProject(); const res = runSearch(root, home, ['build', '--kind', 'bus', '--kind-mode', 'none']); From 84092890371b20dbab7c37513ed0367f346634ac Mon Sep 17 00:00:00 2001 From: "mads.thines" Date: Tue, 20 Oct 2026 15:09:50 +0000 Subject: [PATCH 8/9] docs: document the full set of list/search dimension filters Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com> --- docs/cli.md | 30 +++++++++++++++----------- packages/web/src/content/docs/tags.mdx | 5 +++++ 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index d7c0a7e72..c5d61cd5a 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -96,16 +96,20 @@ without moving its version. See Alias `ls` — read-only human view of the lessons for the cwd's scopes, project/branch/repo/global, split into an **Offline** local-store section and a **Remote** hosted section that degrades -gracefully when unconfigured; `--json`/`--scope`; `--kind a,b`/`--host a,b` narrow by taxonomy, -each with `--kind-mode`/`--host-mode in|nin` [default `in`] — `nin` follows the hosted -`lorekit_match_text` rule (migration 00066): the value must be SET and none of the list, so an -unclassified row matches neither mode; `--tags a,b` narrows by tags, combined by -`--tags-mode any|all|none` [default `any`, the hosted `GET /memories` default]. All three are -parsed by `parseReadFilters` (`src/shared/flags.mjs`, reusing the `policy`/`groom` dimension -table), forwarded to the remote, AND applied to both stores by the one `entryFilter` predicate in -`lessons-view.mjs` (the pure `matchesTags` lives in `lessons-pure.mjs` because the local store -applies it too); a bare value flag, or a mode without its value flag, is a usage error rather than -an unfiltered listing; `--all` drains all pages via `gatherStream` +gracefully when unconfigured; `--json`/`--scope`; the eight dimension filters `policy`/`groom` +take — `--tags a,b` combined by `--tags-mode any|all|none` [default `any`, the hosted +`GET /memories` default], and `--kind`/`--host`/`--trigger`/`--source-agent`/`--origin-repo`/ +`--origin-branch`/`--origin-pr a,b`, each with `---mode in|nin` [default `in`]; `nin` follows +the hosted `lorekit_match_text`/`lorekit_match_int` rule (migration 00066): the value must be SET +and none of the list, so a row without that field matches neither mode; `--origin-pr` takes PR +numbers only (`#482` accepted, `007` reads as 7), because the hosted route DROPS a non-numeric +value and then applies no PR filter. All are parsed by `parseReadFilters` (`src/shared/flags.mjs`, +reusing the `policy`/`groom` `DIMENSIONS` table), forwarded to the remote, AND applied to both +stores by the one `entryFilter` predicate in `lessons-view.mjs` over `normalizeEntry`'s output, +which carries `source_agent`/`trigger`/`origin_*` for that reason (so `--json` shows them too; the +pure `matchesTags` lives in `lessons-pure.mjs` because the local store applies it too); a bare +value flag, or a mode without its value flag, is a usage error rather than an unfiltered listing; +`--all` drains all pages via `gatherStream` [default is single-page `gather()`]; `--max ` hard-caps entries surveyed [default 5000]; `--since`/`--until ` forward `created_since`/`created_until` to the REST request; independently builds BOTH stores via `src/shared/stores.mjs` + renders via the reusable @@ -120,9 +124,9 @@ case-insensitive** substring against a lesson's key OR value, via the pure `matc dependency-free `lessons-pure.mjs`, re-exported by `lessons-view.mjs`)/`filterGroups` helpers over the shared `gather()` output — NOT each store's native `search` method, whose semantics diverge (local literal-substring vs remote server-FTS); regex metachars are matched verbatim, never -compiled; `--json`/`--scope`; `--kind`/`--host` (+ `--kind-mode`/`--host-mode in|nin`) and -`--tags` (+ `--tags-mode any|all|none`) narrow the candidates before the query is matched, exactly -as on `list` +compiled; `--json`/`--scope`; the same eight dimension filters as `list` (`--tags` + +`--tags-mode any|all|none`; `--kind`/`--host`/`--trigger`/`--source-agent`/`--origin-*` + +`---mode in|nin`) narrow the candidates before the query is matched ### `show` diff --git a/packages/web/src/content/docs/tags.mdx b/packages/web/src/content/docs/tags.mdx index 39909bb08..2c9164203 100644 --- a/packages/web/src/content/docs/tags.mdx +++ b/packages/web/src/content/docs/tags.mdx @@ -189,6 +189,11 @@ npx @lorekit/cli list --tags loop::aw-lessons npx @lorekit/cli list --kind lesson --host reviewer npx @lorekit/cli list --kind bus --kind-mode nin +# The rest of the Explorer's dimensions: who wrote it, why, and where it came from +npx @lorekit/cli list --source-agent claude --trigger stuck-loop +npx @lorekit/cli list --origin-repo mthines/my-app --origin-pr 482 +npx @lorekit/cli list --origin-branch main --origin-branch-mode nin + # Full-text search, optionally narrowed by tag npx @lorekit/cli search "worktree" npx @lorekit/cli search "worktree" --tags skill::aw From aa533d2b4da9397de31c374beb4ed5a4d5df6f10 Mon Sep 17 00:00:00 2001 From: "mads.thines" Date: Tue, 20 Oct 2026 15:26:25 +0000 Subject: [PATCH 9/9] fix(cli): match --tags after the two local tiers merge address review comment: filter tags after mergeByKey so a project copy that fails the filter can no longer let its shadowed home copy through Addresses the pr-reviewer bot's comment: https://github.com/mthines/lorekit/pull/709#discussion_r4326726733 Refs: https://github.com/mthines/lorekit/pull/709 Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com> --- packages/cli/src/store/local.mjs | 17 +++++++++------ packages/cli/test/store.test.mjs | 37 ++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/store/local.mjs b/packages/cli/src/store/local.mjs index ada9ba925..d9e26e2b5 100644 --- a/packages/cli/src/store/local.mjs +++ b/packages/cli/src/store/local.mjs @@ -450,12 +450,17 @@ class TwoTierStore { return this.projectActive() ? this.project : this.home; } - async list({ scope, tags, tags_mode, limit } = {}) { - const homeRes = await this.home.list({ scope, tags, tags_mode }); - const projRes = this.projectActive() - ? await this.project.list({ scope, tags, tags_mode }) - : { entries: [] }; - const merged = mergeByKey(projRes.entries, homeRes.entries, (e) => e.key); + // Tags are matched AFTER the tiers merge, never per tier: filtering each + // tier first would let a home copy through whenever the project copy that + // shadows it fails the filter — listing a stale lesson the unfiltered view + // hides. `tags_mode` defaults to `all`, LocalStore.list's own default. + async list({ scope, tags, tags_mode = 'all', limit } = {}) { + const homeRes = await this.home.list({ scope }); + const projRes = this.projectActive() ? await this.project.list({ scope }) : { entries: [] }; + let merged = mergeByKey(projRes.entries, homeRes.entries, (e) => e.key); + if (Array.isArray(tags) && tags.length) { + merged = merged.filter((e) => matchesTags(e.tags, tags, tags_mode)); + } merged.sort((a, b) => String(b.updated || '').localeCompare(String(a.updated || ''))); return { ok: true, entries: limit ? merged.slice(0, limit) : merged }; } diff --git a/packages/cli/test/store.test.mjs b/packages/cli/test/store.test.mjs index 09215500b..03aae9db8 100644 --- a/packages/cli/test/store.test.mjs +++ b/packages/cli/test/store.test.mjs @@ -319,6 +319,43 @@ test('two-tier read merges both tiers; project wins on key collision', async () assert.equal((await store.read({ scope: 'repo::o/r', key: 'shared' })).entry.value, 'project-val'); }); +test('two-tier list matches tags after the merge, so a shadowed home copy never surfaces', async () => { + // Filtering each tier before the merge would drop the project copy that fails + // the filter and then let the home copy it shadows win the key — listing a + // stale lesson the unfiltered view hides. Every assertion checks the VALUE so + // it proves which tier won, not just how many rows came back. + const home = tmpDir(); + const project = tmpDir(); // exists → opted-in + const scope = 'project::widget'; + await createLocalStore(home).write({ scope, key: 'shared', tags: ['perf'], value: 'stale home' }); + await createLocalStore(project).write({ scope, key: 'shared', tags: ['ci'], value: 'fresh project' }); + const store = createTwoTierStore({ home, project }); + const valuesFor = async (args) => + (await store.list({ scope, ...args })).entries.filter((e) => e.key === 'shared').map((e) => e.value); + + assert.deepEqual(await valuesFor({}), ['fresh project'], 'unfiltered: the project copy shadows home'); + assert.deepEqual( + await valuesFor({ tags: ['perf'], tags_mode: 'any' }), + [], + 'any: the project copy fails the filter, and its shadowed home copy is not listed instead', + ); + assert.deepEqual( + await valuesFor({ tags: ['ci'], tags_mode: 'none' }), + [], + 'none: the project copy fails the filter, and its shadowed home copy is not listed instead', + ); + assert.deepEqual( + await valuesFor({ tags: ['ci'], tags_mode: 'any' }), + ['fresh project'], + 'any: the project copy matches and is returned', + ); + assert.deepEqual( + await valuesFor({ tags: ['perf'] }), + [], + 'no mode (all): the shadowed home copy is not listed', + ); +}); + test('two-tier write-routing: global→home; repo→project when opted-in, else home', async () => { const home = tmpDir(); const project = path.join(tmpDir(), '.lorekit'); // does NOT exist yet → not opted-in