diff --git a/docs/cli.md b/docs/cli.md index e22d9b18d..c5d61cd5a 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -96,7 +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`; `--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 @@ -111,7 +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` +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/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/cli/bin/lorekit.mjs b/packages/cli/bin/lorekit.mjs index 7107227e0..ea0f75d05 100755 --- a/packages/cli/bin/lorekit.mjs +++ b/packages/cli/bin/lorekit.mjs @@ -350,6 +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) + --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) @@ -360,6 +367,12 @@ ${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 --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)')} @@ -376,6 +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) + --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) @@ -386,6 +406,9 @@ ${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 --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 20d8d8117..7b8e29a10 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 { parseReadFilters } 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) { @@ -41,11 +42,16 @@ 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; + // 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}`); + return 1; + } + 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. @@ -77,16 +83,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 +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, + // 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 0bd791fa7..c1628326f 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 { parseReadFilters } 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]] [--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; } @@ -43,6 +44,17 @@ 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); + // 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}`); + return 1; + } + 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. if (args.link) { @@ -65,10 +77,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 +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, + // 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 bdb816cd2..bcb7c5d92 100644 --- a/packages/cli/src/shared/completions.mjs +++ b/packages/cli/src/shared/completions.mjs @@ -51,16 +51,26 @@ 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' }, - 'source-agent': { desc: 'Source agent name to record', arg: 'name' }, + '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', 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' }, @@ -111,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', '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', '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 2c47f91d6..a8f64b82c 100644 --- a/packages/cli/src/shared/flags.mjs +++ b/packages/cli/src/shared/flags.mjs @@ -87,3 +87,84 @@ export function parseDimensionConditions(args, { clearable = false } = {}) { } return { conditions }; } + +/** + * 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_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]`, 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 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, 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, field, modes } of DIMENSIONS) { + const raw = args[flag]; + const modeRaw = args[`${flag}-mode`]; + + 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; + } + + 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} ${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] = [...new Set(values)]; + filters[`${field}_mode`] = modeRaw ?? modes[0]; + } + return { filters }; +} 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..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. @@ -17,15 +18,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, @@ -69,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, }; } @@ -576,6 +601,61 @@ 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; +} + +// 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 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 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) => + 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 // `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 +663,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..d9e26e2b5 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,12 +450,17 @@ class TwoTierStore { return this.projectActive() ? this.project : this.home; } - async list({ scope, tags, limit } = {}) { - const homeRes = await this.home.list({ scope, tags }); - const projRes = this.projectActive() - ? await this.project.list({ scope, tags }) - : { 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/src/store/remote.mjs b/packages/cli/src/store/remote.mjs index c98dc2e92..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,12 +196,20 @@ class RemoteStore { // ── Memory operations → REST ────────────────────────────────────────────── - async list({ scope, tags, kind, host, 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); - if (tags?.length) p.set('tags', Array.isArray(tags) ? tags.join(',') : tags); - if (kind) p.set('kind', Array.isArray(kind) ? kind.join(',') : kind); - if (host) p.set('host', Array.isArray(host) ? host.join(',') : host); + // 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); if (created_since) p.set('created_since', created_since); 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/cli/test/list.test.mjs b/packages/cli/test/list.test.mjs index a7651f754..42bce38fc 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, matchesScalar, entryFilter } from '../src/shared/lessons-view.mjs'; import { remoteUnavailableReason } from '../src/shared/stores.mjs'; +import { parseReadFilters } 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)); @@ -57,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( @@ -111,6 +118,204 @@ test('gather captures a per-scope read failure instead of throwing', async () => assert.equal(groups[0].error, 'fetch failed'); }); +// ── unit: the --tags / --kind / --host filters ──────────────────────────────── + +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('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); + 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('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('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' }), { + filters: { tags: ['perf', 'ci'], tags_mode: 'any' }, + }); + assert.deepEqual(parseReadFilters({ tags: 'perf', 'tags-mode': 'none' }), { + filters: { tags: ['perf'], tags_mode: 'none' }, + }); + 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('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); + 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); +}); + +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); +}); + +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. @@ -127,25 +332,34 @@ 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'], + 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.' }), + 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 }) { +function entry({ scope, key, value, tags = [], kind, host, ...overrides }) { const fm = { scope, key, - tags: [], + tags, + ...(kind ? { kind } : {}), + ...(host ? { host } : {}), source_agent: 'aw', trigger: 'manual', 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`; @@ -237,3 +451,80 @@ 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/); +}); + +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/); +}); + +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 d70d65563..b68c4c78e 100644 --- a/packages/cli/test/search.test.mjs +++ b/packages/cli/test/search.test.mjs @@ -79,16 +79,19 @@ 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, ...overrides }) { const fm = { scope, key, tags, + ...(kind ? { kind } : {}), + ...(host ? { host } : {}), source_agent: 'aw', trigger: 'manual', 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`; @@ -116,11 +119,18 @@ function seedProject() { scope: 'global', key: 'build-cache-tip', value: 'The build cache is flaky on CI.', + kind: 'bus', + host: 'ci', + trigger: 'stuck-loop', }); write(`project/${projectName}/c.md`, { scope: `project::${projectName}`, key: 'widget-build-flags', 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}`, @@ -321,6 +331,68 @@ 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 --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 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']); + 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']); + 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/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 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); }); }); 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/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/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/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..2c9164203 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) | @@ -182,8 +182,22 @@ 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 + +# 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 + +# 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 +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 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` 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 ?? [];