@lorekit/cli 1.34.0 → 1.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/lorekit.mjs CHANGED
@@ -636,6 +636,8 @@ const KNOWN_FLAGS = [
636
636
  'value', 'tags', 'source-agent', 'trigger', 'kind', 'host', 'ttl-days', 'clear-ttl', 'org', 'remote', 'local',
637
637
  'link', 'base', 'q', 'owner', 'range', 'view', 'archived',
638
638
  'origin-repo', 'origin-branch', 'origin-commit', 'origin-pr', 'no-origin',
639
+ // Scale-aware survey flags
640
+ 'all', 'max', 'since', 'until', 'key-prefix',
639
641
  ];
640
642
 
641
643
  // Commands that write to disk / talk to the network on a human's behalf. These
@@ -660,7 +662,7 @@ async function main() {
660
662
  const argv = process.argv.slice(2);
661
663
  const args = parseArgs(argv, {
662
664
  aliases: { d: 'dir', e: 'endpoint', t: 'token', y: 'yes', h: 'help', v: 'version' },
663
- booleans: ['yes', 'force', 'deep', 'apply', 'help', 'version', 'global', 'project', 'no-hooks', 'no-origin', 'json', 'remote', 'local', 'link', 'archived', 'clear-ttl', 'telemetry'],
665
+ booleans: ['yes', 'force', 'deep', 'apply', 'help', 'version', 'global', 'project', 'no-hooks', 'no-origin', 'json', 'remote', 'local', 'link', 'archived', 'clear-ttl', 'telemetry', 'all'],
664
666
  known: KNOWN_FLAGS,
665
667
  });
666
668
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lorekit/cli",
3
- "version": "1.34.0",
3
+ "version": "1.35.0",
4
4
  "description": "Install the LoreKit shared-memory skill and run health checks for the LoreKit MCP server.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -117,8 +117,9 @@ archiving and deleting always go through `memory.delete` / `memory.archive`.
117
117
  |-----|-----|---------|
118
118
  | Count lessons per scope/store | `lorekit stats [--scope <s>]` | CLI (read) |
119
119
  | Inventory every scope + lesson count | `lorekit scopes` | CLI (read) |
120
- | Find low-quality lessons | `lorekit lint --json` | CLI (read) |
121
- | Find near-duplicate clusters | `lorekit dedupe --json [--threshold <n>]` | CLI (read) |
120
+ | Find low-quality lessons | `lorekit lint --json [--since <date>] [--until <date>] [--max <n>]` | CLI (read) |
121
+ | Find near-duplicate clusters | `lorekit dedupe --json [--threshold <n>] [--key-prefix <p>] [--since <date>] [--max <n>]` | CLI (read) |
122
+ | List all lessons across large scopes | `lorekit list --all [--max <n>] [--since <date>] [--until <date>]` | CLI (read) |
122
123
  | Read one lesson in full | `lorekit show <scope::key> [--json]` | CLI (read) |
123
124
  | Compare offline vs remote | `lorekit diff` | CLI (read) |
124
125
  | Write the merged/consolidated lesson | `memory.write` (or `lorekit write`) | MCP / CLI |
@@ -126,6 +127,37 @@ archiving and deleting always go through `memory.delete` / `memory.archive`.
126
127
  | Archive a lesson (reversible) | `memory.archive` (or `memory.delete`) | **MCP only** |
127
128
  | Hard-delete a lesson (permanent) | `memory.delete { force: true }` | **MCP only** |
128
129
 
130
+ ### Survey flags for large scopes
131
+
132
+ When a scope holds more lessons than a single page (> 50 for `list`, > 100 for
133
+ `lint`/`dedupe`), use these flags to survey the full population or narrow it:
134
+
135
+ | Flag | Commands | Effect |
136
+ |------|----------|--------|
137
+ | `--all` | `list` | Drain all pages (default for `lint`/`dedupe`) |
138
+ | `--max <n>` | `list --all`, `lint`, `dedupe` | Hard cap on entries surveyed (default 5000) |
139
+ | `--since <iso-date>` | `list --all`, `lint`, `dedupe` | Lower date bound (`created_since`) |
140
+ | `--until <iso-date>` | `list --all`, `lint`, `dedupe` | Upper date bound (`created_until`) |
141
+ | `--key-prefix <p>` | `dedupe` | Narrow deduplication to keys starting with prefix |
142
+
143
+ `dedupe` applies an internal population cap of 2000 for memory safety. When the
144
+ cap is reached, the output includes a warning with narrowing suggestions — use
145
+ `--key-prefix` or `--since` to reduce the population.
146
+
147
+ ### MCP cursor paging
148
+
149
+ `memory.list` and `memory.search` now support pagination. When more results
150
+ exist than fit in one response, the response includes `hasMore: true` and a
151
+ `nextCursor` string. Pass `nextCursor` as the `cursor` argument on the next
152
+ call to retrieve the following page. Omit `cursor` to start from the first page.
153
+
154
+ ```json
155
+ { "scope": "global", "limit": 100, "cursor": "<nextCursor from previous response>" }
156
+ ```
157
+
158
+ Use this to drain an arbitrarily large scope in an agent loop rather than
159
+ relying on a truncated first page.
160
+
129
161
  > **`dedupe` is a heuristic, not a semantic judge.** It clusters on Jaccard
130
162
  > word-token overlap, so it can both miss reworded duplicates *and* group
131
163
  > coincidental ones. Treat every cluster as a candidate to read and decide on,
@@ -23,9 +23,21 @@ lorekit scopes # every scope in the store + its lesson count
23
23
  how you notice a `branch::…` scope you had forgotten about, or a scope with
24
24
  hundreds of lessons that dwarfs the rest. Neither command reports a
25
25
  last-activity date — the inventory is counts only — so judge staleness from the
26
- lessons themselves once you narrow in (`lorekit list --scope <scope>` tags each
27
- one with its `updated` date). Read both, then pick the noisiest scope as the
28
- target for this pass and narrow to it with `--scope <scope>` from here on.
26
+ lessons themselves once you narrow in. Read both, then pick the noisiest scope as
27
+ the target for this pass and narrow to it with `--scope <scope>` from here on.
28
+
29
+ **Large scopes:** when a scope has more lessons than a single page, use:
30
+
31
+ ```bash
32
+ lorekit list --all --scope <scope> # drain all pages; tags each lesson with its updated date
33
+ lorekit list --all --scope <scope> --max 500 # cap at 500 to get a representative sample
34
+ lorekit list --all --scope <scope> --since 2024-01-01 # only lessons created since that date
35
+ ```
36
+
37
+ The remote store is paginated (default 50 entries per `list` call, 100 per
38
+ `lint`/`dedupe` call). `--all` drains every page; `--max` applies a hard cap
39
+ (default 5000). `lint` and `dedupe` default to full-scope survey — add `--max`
40
+ or `--since` when the population is large enough to slow things down.
29
41
 
30
42
  ## Phase 2 — Lint (read-only)
31
43
 
@@ -56,6 +68,9 @@ makes it a clean CI gate — a passing `lint` is your Phase 6 proof.
56
68
 
57
69
  ```bash
58
70
  lorekit dedupe --json --scope <scope> --threshold 0.85
71
+ # For large scopes, narrow the population:
72
+ lorekit dedupe --json --scope <scope> --threshold 0.85 --key-prefix "debug-" --max 1000
73
+ lorekit dedupe --json --scope <scope> --threshold 0.85 --since 2024-01-01
59
74
  ```
60
75
 
61
76
  Each cluster is a set of lessons whose values overlap heavily by word tokens.
@@ -64,6 +79,11 @@ Start high (`0.85`) to see the confident duplicates, then re-run lower (`0.75`,
64
79
  clusters are coincidental overlaps rather than true duplicates, so read more
65
80
  carefully.
66
81
 
82
+ `dedupe` surveys the full scope by default. When the population exceeds 2000
83
+ entries it stops and prints a narrowing warning — use `--key-prefix` to focus
84
+ on a key namespace, or `--since` to limit the date range. The `--max` flag sets
85
+ a lower cap (default 5000, internal safety cap at 2000).
86
+
67
87
  For every cluster you intend to act on, read the members in full first:
68
88
 
69
89
  ```bash
package/src/dedupe.mjs CHANGED
@@ -15,18 +15,30 @@ import { resolveProjectRoot, readLorekitJson } from './config.mjs';
15
15
  import { deriveScope } from './scope.mjs';
16
16
  import { resolveDenies } from './control.mjs';
17
17
  import { resolveStores, remoteUnavailableReason } from './stores.mjs';
18
- import { scopeList, gather, clusterDuplicates } from './lessons-view.mjs';
18
+ import { scopeList, gather, gatherStream, clusterDuplicates, clusterDuplicatesBlocked, DEFAULT_MAX } from './lessons-view.mjs';
19
19
  import { log, heading, status, c } from './util.mjs';
20
20
 
21
21
  const DEFAULT_THRESHOLD = 0.8;
22
+ // Maximum entries to accumulate before the token-blocking index becomes
23
+ // memory-prohibitive. Beyond this the user must narrow via --key-prefix /
24
+ // --since / --max.
25
+ const DEDUPE_POP_CAP = 2000;
22
26
 
23
- // Parse `--threshold` into a number in [0, 1]; anything unparseable or out of
27
+ // Smallest threshold the blocked clusterer accepts. `clusterDuplicatesBlocked`
28
+ // is provably equivalent to the oracle `clusterDuplicates` only for
29
+ // threshold > 0 (at 0 the oracle clusters even zero-overlap pairs, which the
30
+ // token-blocking sweep never generates). Any positive value below the smallest
31
+ // possible Jaccard behaves identically to 0+ while preserving that invariant,
32
+ // so we floor to a tiny epsilon rather than accept a literal 0.
33
+ const MIN_THRESHOLD = Number.EPSILON;
34
+
35
+ // Parse `--threshold` into a number in (0, 1]; anything unparseable or out of
24
36
  // range falls back to the default (never a crash on bad input). Pure-ish helper.
25
37
  export function parseThreshold(raw) {
26
38
  if (raw === undefined || raw === true) return DEFAULT_THRESHOLD;
27
39
  const n = Number(raw);
28
40
  if (!Number.isFinite(n)) return DEFAULT_THRESHOLD;
29
- return Math.min(1, Math.max(0, n));
41
+ return Math.min(1, Math.max(MIN_THRESHOLD, n));
30
42
  }
31
43
 
32
44
  // Read `dedupe.threshold` from .lorekit.json (non-throwing). Returns the
@@ -78,19 +90,93 @@ export async function dedupe(args) {
78
90
  // Deny-wins section suppression, identical to the other read commands.
79
91
  const { localDenied, remoteDenied } = resolveDenies(root, { env });
80
92
 
81
- const buildSection = (flat) => ({
82
- available: true,
83
- clusters: clusterDuplicates(flat.entries, threshold),
84
- errored: flat.errored,
85
- });
93
+ // `dedupe` defaults to full-scope survey. --max, --since, --key-prefix narrow
94
+ // the population. Population cap: 2000. Past it stop accumulating, warn, and
95
+ // surface a narrowing hint. Use token-blocking (`clusterDuplicatesBlocked`)
96
+ // for the one super-linear operation.
97
+ const surveyMax = args.max !== undefined ? Number(args.max) : DEFAULT_MAX;
98
+ const surveySince = args.since || undefined;
99
+ const surveyUntil = args.until || undefined;
100
+ const surveyKeyPrefix = args['key-prefix'] || undefined;
101
+
102
+ // Stream-accumulate entries up to DEDUPE_POP_CAP and note when capped.
103
+ async function streamAccumulate(store) {
104
+ const accumulated = [];
105
+ const errored = [];
106
+ let popCapped = false;
107
+
108
+ await gatherStream(store, scopes, {
109
+ max: surveyMax,
110
+ since: surveySince,
111
+ until: surveyUntil,
112
+ keyPrefix: surveyKeyPrefix,
113
+ onPage: ({ scope, entries }) => {
114
+ if (popCapped) return;
115
+ for (const e of entries) {
116
+ if (accumulated.length >= DEDUPE_POP_CAP) {
117
+ popCapped = true;
118
+ break;
119
+ }
120
+ accumulated.push({ ...e, scope: e.scope ?? scope });
121
+ }
122
+ },
123
+ });
124
+
125
+ return { entries: accumulated, errored, popCapped };
126
+ }
127
+
128
+ const buildSection = async (store, local) => {
129
+ if (local) {
130
+ // Local store is already exhaustive; gather everything, then apply the
131
+ // SAME narrowing the remote path gets server-side (the local store's
132
+ // `list()` honours only scope/tags, so it can't narrow itself). Filtering
133
+ // here — before the population cap — is what makes `--key-prefix`/
134
+ // `--since`/`--until`/`--max` real offline instead of silent no-ops.
135
+ const flat = flatten(await gather(store, scopes));
136
+ let entries = flat.entries;
137
+ if (surveyKeyPrefix) {
138
+ entries = entries.filter(
139
+ (e) => typeof e.key === 'string' && e.key.startsWith(surveyKeyPrefix),
140
+ );
141
+ }
142
+ // `created_at` is compared as an ISO string; both bounds mirror the REST
143
+ // handler — inclusive `since`, exclusive `until` (the `[since, until)`
144
+ // window). An entry with no `created` timestamp is kept (never dropped by
145
+ // a bound it can't be judged against).
146
+ if (surveySince) entries = entries.filter((e) => !e.created || e.created >= surveySince);
147
+ if (surveyUntil) entries = entries.filter((e) => !e.created || e.created < surveyUntil);
148
+ // Silent `--max` cap first, then the memory-safety population cap that
149
+ // drives the "partial results" warning — mirroring the remote path where
150
+ // gatherStream's `max` and the DEDUPE_POP_CAP are distinct.
151
+ if (entries.length > surveyMax) entries = entries.slice(0, surveyMax);
152
+ let popCapped = false;
153
+ if (entries.length > DEDUPE_POP_CAP) {
154
+ entries = entries.slice(0, DEDUPE_POP_CAP);
155
+ popCapped = true;
156
+ }
157
+ return {
158
+ available: true,
159
+ clusters: clusterDuplicatesBlocked(entries, threshold),
160
+ errored: flat.errored,
161
+ popCapped,
162
+ };
163
+ }
164
+ const { entries, errored, popCapped } = await streamAccumulate(store);
165
+ return {
166
+ available: true,
167
+ clusters: clusterDuplicatesBlocked(entries, threshold),
168
+ errored,
169
+ popCapped,
170
+ };
171
+ };
86
172
 
87
173
  const offlineSection = localDenied
88
174
  ? { available: false, reason: `disabled by deny constraint (${localDenied.source})` }
89
- : buildSection(flatten(await gather(local, scopes)));
175
+ : await buildSection(local, true);
90
176
 
91
177
  const remoteAvailable = !remoteDenied && remote.usable();
92
178
  const remoteSection = remoteAvailable
93
- ? buildSection(flatten(await gather(remote, scopes)))
179
+ ? await buildSection(remote, false)
94
180
  : {
95
181
  available: false,
96
182
  reason: remoteDenied
@@ -109,6 +195,13 @@ export async function dedupe(args) {
109
195
  log(` scopes: ${scopes.join(' → ')}`);
110
196
  log(` ${c.dim(`heuristic: Jaccard word-token overlap >= ${threshold} (not semantic)`)}`);
111
197
 
198
+ if (offlineSection.available && offlineSection.popCapped) {
199
+ log(` ${c.yellow('!')} population cap (${DEDUPE_POP_CAP}) reached for Offline — results are partial. Narrow with --key-prefix, --since, or --max.`);
200
+ }
201
+ if (remoteSection.available && remoteSection.popCapped) {
202
+ log(` ${c.yellow('!')} population cap (${DEDUPE_POP_CAP}) reached for Remote — results are partial. Narrow with --key-prefix, --since, or --max.`);
203
+ }
204
+
112
205
  renderDedupeSection({ title: 'Offline' }, offlineSection);
113
206
  renderDedupeSection(
114
207
  { title: 'Remote', subtitle: remoteAvailable ? connection.endpoint : undefined },
@@ -65,6 +65,7 @@ export function normalizeEntry(e = {}) {
65
65
  key: e.key ?? null,
66
66
  value: e.value == null ? '' : String(e.value),
67
67
  updated: e.updated ?? e.updated_at ?? null,
68
+ created: e.created ?? e.created_at ?? null,
68
69
  tags,
69
70
  kind: e.kind ?? inferred.kind ?? null,
70
71
  host: e.host ?? inferred.host ?? null,
@@ -433,6 +434,8 @@ export function clusterDuplicates(entries = [], threshold = 0.8) {
433
434
  // `store.list({scope})` contract. Returns ordered per-scope groups plus a total
434
435
  // — a per-scope read failure is captured on the group, never thrown, so one bad
435
436
  // scope can't abort the listing. `store` may be a local or remote store.
437
+ // Single-page only (the existing default behaviour). For full-scope traversal
438
+ // use `gatherStream` below.
436
439
  export async function gather(store, scopes, filters = {}) {
437
440
  // Parse the taxonomy filters into value sets. `filters` is passed to the store
438
441
  // too (the remote narrows server-side); we ALSO post-filter the normalized
@@ -468,6 +471,208 @@ export async function gather(store, scopes, filters = {}) {
468
471
  return { groups, total };
469
472
  }
470
473
 
474
+ // Default maximum entries to survey in a full-scope traversal. High enough to
475
+ // cover almost every real scope; callers can raise or lower via `--max`.
476
+ export const DEFAULT_MAX = 5000;
477
+
478
+ // Page size used by `gatherStream` for remote stores.
479
+ const STREAM_PAGE_LIMIT = 100;
480
+
481
+ // Stream every page of entries across `scopes` from `store`, invoking
482
+ // `onPage({ scope, entries })` once per page as entries arrive. Designed for
483
+ // linear consumers (lint, stats, dedupe) that process page-by-page without
484
+ // accumulating all rows in memory.
485
+ //
486
+ // Options:
487
+ // max — hard cap on total surveyed entries (default: DEFAULT_MAX). When
488
+ // reached the walk stops and `capped` is set in the result.
489
+ // since — ISO date/timestamp lower bound, forwarded as `created_since`.
490
+ // until — ISO date/timestamp upper bound, forwarded as `created_until`.
491
+ // keyPrefix — key prefix filter, forwarded as `key_prefix`.
492
+ // onPage — callback invoked with `{ scope, entries }` per page.
493
+ //
494
+ // Returns `{ surveyed, total, capped, byScope }` where:
495
+ // surveyed — total entries delivered to `onPage`.
496
+ // total — exact aggregate from `listScopes()` when available (remote),
497
+ // else equal to `surveyed`.
498
+ // capped — true when `max` stopped the walk before all pages were read.
499
+ // byScope — `[{ scope, count, error }]` per scope (error=null on success).
500
+ //
501
+ // A per-scope read failure is captured on `byScope`, never thrown — mirrors
502
+ // `gather()`'s resilience contract. `LocalStore.list` returns everything in one
503
+ // page (no `nextCursor`), so the loop naturally terminates after one iteration
504
+ // offline and adds no overhead.
505
+ export async function gatherStream(store, scopes, {
506
+ max = DEFAULT_MAX,
507
+ since,
508
+ until,
509
+ keyPrefix,
510
+ onPage,
511
+ } = {}) {
512
+ const byScope = [];
513
+ let surveyed = 0;
514
+ let capped = false;
515
+
516
+ // Attempt to get exact aggregate counts for the remote store via listScopes().
517
+ // Only available on stores that implement listScopes(); degrades gracefully.
518
+ let scopeCountMap = null;
519
+ if (typeof store.listScopes === 'function') {
520
+ try {
521
+ const ls = await store.listScopes();
522
+ if (ls && ls.ok && Array.isArray(ls.scopes)) {
523
+ scopeCountMap = new Map(ls.scopes.map((s) => [s.scope, s.count]));
524
+ }
525
+ } catch { /* best-effort */ }
526
+ }
527
+
528
+ for (const scope of scopes) {
529
+ if (capped) {
530
+ byScope.push({ scope, count: 0, error: null });
531
+ continue;
532
+ }
533
+ let cursor = undefined;
534
+ let scopeCount = 0;
535
+ let scopeError = null;
536
+
537
+ // eslint-disable-next-line no-constant-condition
538
+ while (true) {
539
+ const remaining = max - surveyed;
540
+ if (remaining <= 0) {
541
+ capped = true;
542
+ break;
543
+ }
544
+ const pageLimit = Math.min(STREAM_PAGE_LIMIT, remaining);
545
+ let res;
546
+ try {
547
+ res = await store.list({
548
+ scope,
549
+ limit: pageLimit,
550
+ cursor,
551
+ ...(since ? { created_since: since } : {}),
552
+ ...(until ? { created_until: until } : {}),
553
+ ...(keyPrefix ? { key_prefix: keyPrefix } : {}),
554
+ });
555
+ } catch (e) {
556
+ res = { ok: false, networkError: (e && e.message) || 'error' };
557
+ }
558
+ if (!res || res.ok === false) {
559
+ scopeError = describeError(res);
560
+ break;
561
+ }
562
+ const entries = (res.entries || []).map(normalizeEntry);
563
+ if (entries.length > 0) {
564
+ if (onPage) onPage({ scope, entries });
565
+ scopeCount += entries.length;
566
+ surveyed += entries.length;
567
+ }
568
+ // hasMore absent (local store) or false → done with this scope.
569
+ if (!res.hasMore || !res.nextCursor) break;
570
+ cursor = res.nextCursor;
571
+ // Safety: stop if we hit the cap after this page.
572
+ if (surveyed >= max) {
573
+ capped = true;
574
+ break;
575
+ }
576
+ }
577
+ byScope.push({ scope, count: scopeCount, error: scopeError });
578
+ }
579
+
580
+ // Derive total: use the aggregate when available (remote), else surveyed.
581
+ let total = surveyed;
582
+ if (scopeCountMap) {
583
+ total = 0;
584
+ for (const scope of scopes) {
585
+ total += scopeCountMap.get(scope) ?? 0;
586
+ }
587
+ }
588
+
589
+ return { surveyed, total, capped, byScope };
590
+ }
591
+
592
+ // Token-blocking near-duplicate clustering — a performance-bounded alternative
593
+ // to `clusterDuplicates`'s O(n²) all-pairs sweep. Uses an inverted index
594
+ // (token → indices) to generate only the candidate pairs that share at least
595
+ // one token, then applies the SAME Jaccard `>= threshold` + union-find logic as
596
+ // `clusterDuplicates`. The resulting clusters are provably identical:
597
+ //
598
+ // IF similarity(a, b) >= threshold > 0 THEN a and b share at least one token,
599
+ // so they WILL appear as a candidate pair in the inverted-index sweep.
600
+ //
601
+ // Keeping `clusterDuplicates` intact means equivalence is a testable property
602
+ // (AC-7) rather than a rewrite claim. Pure.
603
+ export function clusterDuplicatesBlocked(entries = [], threshold = 0.8) {
604
+ const items = entries.map((e, i) => ({
605
+ i,
606
+ scope: e.scope ?? null,
607
+ key: e.key ?? null,
608
+ tokens: tokenize(e.value),
609
+ }));
610
+
611
+ // Build inverted index: token → [item indices]
612
+ /** @type {Map<string, number[]>} */
613
+ const invertedIndex = new Map();
614
+ for (const item of items) {
615
+ for (const token of item.tokens) {
616
+ if (!invertedIndex.has(token)) invertedIndex.set(token, []);
617
+ invertedIndex.get(token).push(item.i);
618
+ }
619
+ }
620
+
621
+ // Generate candidate pairs: any two items sharing at least one token.
622
+ // Use a Set of encoded pair keys to avoid duplicates.
623
+ const candidatePairs = new Set();
624
+ for (const indices of invertedIndex.values()) {
625
+ for (let x = 0; x < indices.length; x += 1) {
626
+ for (let y = x + 1; y < indices.length; y += 1) {
627
+ const a = Math.min(indices[x], indices[y]);
628
+ const b = Math.max(indices[x], indices[y]);
629
+ candidatePairs.add(`${a}:${b}`);
630
+ }
631
+ }
632
+ }
633
+
634
+ // Apply the SAME union-find as clusterDuplicates over candidate pairs only.
635
+ const parent = items.map((_, i) => i);
636
+ const find = (x) => {
637
+ while (parent[x] !== x) {
638
+ parent[x] = parent[parent[x]];
639
+ x = parent[x];
640
+ }
641
+ return x;
642
+ };
643
+ const pairs = [];
644
+ for (const pairKey of candidatePairs) {
645
+ const [a, b] = pairKey.split(':').map(Number);
646
+ const sim = similarity(items[a].tokens, items[b].tokens);
647
+ if (sim >= threshold) {
648
+ pairs.push({ a, b, sim });
649
+ parent[find(a)] = find(b);
650
+ }
651
+ }
652
+
653
+ // Assemble clusters — identical logic to clusterDuplicates.
654
+ const byRoot = new Map();
655
+ for (const it of items) {
656
+ const r = find(it.i);
657
+ if (!byRoot.has(r)) byRoot.set(r, []);
658
+ byRoot.get(r).push(it);
659
+ }
660
+ const clusters = [];
661
+ for (const members of byRoot.values()) {
662
+ if (members.length < 2) continue;
663
+ const idx = new Set(members.map((m) => m.i));
664
+ const sims = pairs.filter((p) => idx.has(p.a) && idx.has(p.b)).map((p) => p.sim);
665
+ clusters.push({
666
+ members: members.map((m) => ({ scope: m.scope, key: m.key })),
667
+ size: members.length,
668
+ minSimilarity: sims.length ? Math.min(...sims) : threshold,
669
+ maxSimilarity: sims.length ? Math.max(...sims) : threshold,
670
+ });
671
+ }
672
+ clusters.sort((x, y) => y.size - x.size);
673
+ return clusters;
674
+ }
675
+
471
676
  // Render one section (Offline or Remote). `section` is either
472
677
  // { available:false, reason } → a graceful note, or
473
678
  // { available:true, groups, total } → grouped lessons.
package/src/lint.mjs CHANGED
@@ -15,7 +15,7 @@ import { resolveProjectRoot } from './config.mjs';
15
15
  import { deriveScope } from './scope.mjs';
16
16
  import { resolveDenies } from './control.mjs';
17
17
  import { resolveStores, remoteUnavailableReason } from './stores.mjs';
18
- import { scopeList, gather, lintGroups } from './lessons-view.mjs';
18
+ import { scopeList, gather, gatherStream, lintGroups, DEFAULT_MAX } from './lessons-view.mjs';
19
19
  import { log, heading, status, c } from './util.mjs';
20
20
 
21
21
  export async function lint(args) {
@@ -36,9 +36,47 @@ export async function lint(args) {
36
36
  // Deny-wins section suppression, identical to the other read commands.
37
37
  const { localDenied, remoteDenied } = resolveDenies(root, { env });
38
38
 
39
- const offlineResult = localDenied ? { groups: [], total: 0 } : lintGroups(await gather(local, scopes));
39
+ // `lint` defaults to full-scope survey (--all). Use gatherStream for remote
40
+ // to drain all pages; gather (single-page) still serves local store which is
41
+ // already exhaustive. --max, --since, --until are forwarded when provided.
42
+ const surveyMax = args.max !== undefined ? Number(args.max) : DEFAULT_MAX;
43
+ const surveySince = args.since || undefined;
44
+ const surveyUntil = args.until || undefined;
45
+
46
+ let offlineResult;
47
+ if (localDenied) {
48
+ offlineResult = { groups: [], total: 0 };
49
+ } else {
50
+ offlineResult = lintGroups(await gather(local, scopes));
51
+ }
52
+
40
53
  const remoteAvailable = !remoteDenied && remote.usable();
41
- const remoteResult = remoteAvailable ? lintGroups(await gather(remote, scopes)) : { groups: [], total: 0 };
54
+ let remoteResult;
55
+ if (!remoteAvailable) {
56
+ remoteResult = { groups: [], total: 0 };
57
+ } else {
58
+ // Stream all pages, accumulating entries per scope for linting.
59
+ const accumulated = new Map();
60
+ for (const scope of scopes) accumulated.set(scope, []);
61
+ await gatherStream(remote, scopes, {
62
+ max: surveyMax,
63
+ since: surveySince,
64
+ until: surveyUntil,
65
+ onPage: ({ scope, entries }) => {
66
+ const arr = accumulated.get(scope);
67
+ if (arr) for (const e of entries) arr.push(e);
68
+ },
69
+ });
70
+ // Build a gather()-shaped result so lintGroups can consume it unchanged.
71
+ const groups = [];
72
+ let total = 0;
73
+ for (const scope of scopes) {
74
+ const entries = accumulated.get(scope) || [];
75
+ total += entries.length;
76
+ groups.push({ scope, entries, error: null });
77
+ }
78
+ remoteResult = lintGroups({ groups, total });
79
+ }
42
80
 
43
81
  const offlineSection = localDenied
44
82
  ? { available: false, reason: `disabled by deny constraint (${localDenied.source})` }
package/src/list.mjs CHANGED
@@ -13,7 +13,7 @@ import { resolveProjectRoot } from './config.mjs';
13
13
  import { deriveScope } from './scope.mjs';
14
14
  import { resolveDenies } from './control.mjs';
15
15
  import { resolveStores, remoteUnavailableReason } from './stores.mjs';
16
- import { scopeList, gather, renderSection } from './lessons-view.mjs';
16
+ import { scopeList, gather, gatherStream, renderSection, DEFAULT_MAX } from './lessons-view.mjs';
17
17
  import { resolveAppBase, mostSpecificScope } from './deeplink-pure.mjs';
18
18
  import { emitLink } from './link.mjs';
19
19
  import { log, heading, c } from './util.mjs';
@@ -69,9 +69,54 @@ export async function list(args) {
69
69
  // agent-facing control model enforces, honored here in the human read view.
70
70
  const { localDenied, remoteDenied } = resolveDenies(root, { env });
71
71
 
72
+ // --all: drain all pages from the remote store (for large scopes). Default
73
+ // is single-page per scope (what `list` has always shown). The local store is
74
+ // always exhaustive regardless of --all.
75
+ const surveyAll = Boolean(args.all);
76
+ const surveyMax = args.max !== undefined ? Number(args.max) : DEFAULT_MAX;
77
+ const surveySince = args.since || undefined;
78
+ const surveyUntil = args.until || undefined;
79
+
80
+ // Taxonomy `keep` predicate mirroring `gather()`'s: `gatherStream` neither
81
+ // forwards `kind`/`host` to the store nor post-filters, so a `--all` drain
82
+ // must apply the same narrowing here or `list --all --kind X` would ignore X.
83
+ const wanted = (v) =>
84
+ v == null ? null : new Set(String(v).split(',').map((s) => s.trim()).filter(Boolean));
85
+ const kindSet = wanted(filters.kind);
86
+ const hostSet = wanted(filters.host);
87
+ const keep = (e) =>
88
+ (!kindSet || (e.kind != null && kindSet.has(e.kind))) &&
89
+ (!hostSet || (e.host != null && hostSet.has(e.host)));
90
+
72
91
  const offline = localDenied ? { groups: [], total: 0 } : await gather(local, scopes, filters);
73
92
  const remoteAvailable = !remoteDenied && remote.usable();
74
- const remoteResult = remoteAvailable ? await gather(remote, scopes, filters) : { groups: [], total: 0 };
93
+ let remoteResult;
94
+ if (!remoteAvailable) {
95
+ remoteResult = { groups: [], total: 0 };
96
+ } else if (surveyAll) {
97
+ // Full drain: accumulate all pages into a groups-shaped result.
98
+ const accumulated = new Map();
99
+ for (const scope of scopes) accumulated.set(scope, []);
100
+ await gatherStream(remote, scopes, {
101
+ max: surveyMax,
102
+ since: surveySince,
103
+ until: surveyUntil,
104
+ onPage: ({ scope, entries }) => {
105
+ const arr = accumulated.get(scope);
106
+ if (arr) for (const e of entries) if (keep(e)) arr.push(e);
107
+ },
108
+ });
109
+ const groups = [];
110
+ let total = 0;
111
+ for (const scope of scopes) {
112
+ const entries = accumulated.get(scope) || [];
113
+ total += entries.length;
114
+ groups.push({ scope, entries, error: null });
115
+ }
116
+ remoteResult = { groups, total };
117
+ } else {
118
+ remoteResult = await gather(remote, scopes, filters);
119
+ }
75
120
 
76
121
  const offlineSection = localDenied
77
122
  ? { available: false, reason: `disabled by deny constraint (${localDenied.source})` }
@@ -100,12 +100,31 @@ export const MEMORY_TOOL_DEFS = [
100
100
  {
101
101
  name: 'memory.list',
102
102
  description: 'List memories for a scope',
103
- inputSchema: { type: 'object', required: ['scope'] },
103
+ inputSchema: {
104
+ type: 'object',
105
+ required: ['scope'],
106
+ properties: {
107
+ scope: { type: 'string' },
108
+ tags: { type: 'array', items: { type: 'string' } },
109
+ limit: { type: 'integer', minimum: 1, maximum: 100, default: 50 },
110
+ cursor: { type: 'string', description: 'Opaque cursor from a previous response\'s nextCursor. Omit to start from the first page.' },
111
+ },
112
+ },
104
113
  },
105
114
  {
106
115
  name: 'memory.search',
107
116
  description: 'Keyword search across memories',
108
- inputSchema: { type: 'object', required: ['q'] },
117
+ inputSchema: {
118
+ type: 'object',
119
+ required: ['q'],
120
+ properties: {
121
+ q: { type: 'string' },
122
+ scopes: { type: 'array', items: { type: 'string' } },
123
+ tags: { type: 'array', items: { type: 'string' } },
124
+ limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
125
+ cursor: { type: 'string', description: 'Opaque cursor from a previous response\'s nextCursor. Omit to start from the first page.' },
126
+ },
127
+ },
109
128
  },
110
129
  {
111
130
  name: 'memory.delete',
package/src/stats.mjs CHANGED
@@ -20,7 +20,7 @@ import { resolveProjectRoot } from './config.mjs';
20
20
  import { deriveScope } from './scope.mjs';
21
21
  import { resolveDenies } from './control.mjs';
22
22
  import { resolveStores, remoteUnavailableReason } from './stores.mjs';
23
- import { scopeList, gather, tallyGroups } from './lessons-view.mjs';
23
+ import { scopeList, gather, tallyGroups, summarizeScopeInventory, filterScopeInventory } from './lessons-view.mjs';
24
24
  import { log, heading, status, c } from './util.mjs';
25
25
 
26
26
  export async function stats(args) {
@@ -44,9 +44,32 @@ export async function stats(args) {
44
44
 
45
45
  const offlineTally = localDenied ? { perScope: [], total: 0 } : tallyGroups(await gather(local, scopes));
46
46
  const remoteAvailable = !remoteDenied && remote.usable();
47
- const remoteTally = remoteAvailable
48
- ? tallyGroups(await gather(remote, scopes))
49
- : { perScope: [], total: 0 };
47
+
48
+ // Remote counts come from the GET /memories/scopes Postgres aggregate
49
+ // (RemoteStore.listScopes), which is exact at any scale — never row-draining.
50
+ // This is the fix for `stats`=50 vs `scopes`=487: the old code used
51
+ // gather() which returned only the first page from the remote store.
52
+ let remoteTally;
53
+ if (!remoteAvailable) {
54
+ remoteTally = { perScope: [], total: 0 };
55
+ } else {
56
+ const scopesRes = await remote.listScopes();
57
+ if (scopesRes && scopesRes.ok && Array.isArray(scopesRes.scopes)) {
58
+ // Filter to only the applicable scopes; total the filtered set.
59
+ const filtered = filterScopeInventory(scopesRes.scopes, null).filter(
60
+ (s) => scopes.includes(s.scope),
61
+ );
62
+ const perScope = scopes.map((scope) => {
63
+ const match = filtered.find((s) => s.scope === scope);
64
+ return { scope, count: match ? match.count : 0, error: null };
65
+ });
66
+ const total = perScope.reduce((n, s) => n + s.count, 0);
67
+ remoteTally = { perScope, total };
68
+ } else {
69
+ // Fallback when listScopes() is unavailable/errors — single-page gather.
70
+ remoteTally = tallyGroups(await gather(remote, scopes));
71
+ }
72
+ }
50
73
 
51
74
  const offlineSection = localDenied
52
75
  ? { available: false, reason: `disabled by deny constraint (${localDenied.source})` }
@@ -58,19 +58,29 @@ class RemoteStore {
58
58
 
59
59
  // ── Memory operations → REST ──────────────────────────────────────────────
60
60
 
61
- async list({ scope, tags, kind, host, limit } = {}) {
61
+ async list({ scope, tags, kind, host, limit, cursor, created_since, created_until, key_prefix } = {}) {
62
62
  const p = new URLSearchParams();
63
63
  if (scope) p.set('scope', scope);
64
64
  if (tags?.length) p.set('tags', Array.isArray(tags) ? tags.join(',') : tags);
65
65
  if (kind) p.set('kind', Array.isArray(kind) ? kind.join(',') : kind);
66
66
  if (host) p.set('host', Array.isArray(host) ? host.join(',') : host);
67
67
  if (limit) p.set('limit', String(limit));
68
+ if (cursor) p.set('cursor', cursor);
69
+ if (created_since) p.set('created_since', created_since);
70
+ if (created_until) p.set('created_until', created_until);
71
+ if (key_prefix) p.set('key_prefix', key_prefix);
68
72
  const res = await this._rest(`/memories?${p}`);
69
73
  if (!res.ok) return { ok: false, error: res.error, networkError: res.networkError };
70
- return { ok: true, entries: res.data?.entries ?? [] };
74
+ const data = res.data ?? {};
75
+ return {
76
+ ok: true,
77
+ entries: data.entries ?? [],
78
+ hasMore: data.hasMore ?? false,
79
+ nextCursor: data.nextCursor ?? null,
80
+ };
71
81
  }
72
82
 
73
- async search({ q, scopes, tags } = {}) {
83
+ async search({ q, scopes, tags, limit, cursor } = {}) {
74
84
  // A list of terms collapses into ONE `websearch` query joined by `OR`, so a
75
85
  // multi-term failure lookup is a single round-trip (the server FTS ORs them
76
86
  // and stems each). `failureQuery` distils terms to `[a-z0-9]+` tokens, so no
@@ -80,9 +90,17 @@ class RemoteStore {
80
90
  if (query) body.q = query;
81
91
  if (scopes?.length) body.scopes = scopes;
82
92
  if (tags?.length) body.tags = tags;
93
+ if (limit) body.limit = limit;
94
+ if (cursor) body.cursor = cursor;
83
95
  const res = await this._rest('/memories/search', { method: 'POST', body });
84
96
  if (!res.ok) return { ok: false, error: res.error, networkError: res.networkError };
85
- return { ok: true, entries: res.data?.entries ?? [] };
97
+ const data = res.data ?? {};
98
+ return {
99
+ ok: true,
100
+ entries: data.entries ?? [],
101
+ hasMore: data.hasMore ?? false,
102
+ nextCursor: data.nextCursor ?? null,
103
+ };
86
104
  }
87
105
 
88
106
  async read({ scope, key } = {}) {