@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 +3 -1
- package/package.json +1 -1
- package/skill/lorekit-groom/SKILL.md +34 -2
- package/skill/lorekit-groom/rules/grooming-pass.md +23 -3
- package/src/dedupe.mjs +103 -10
- package/src/lessons-view.mjs +205 -0
- package/src/lint.mjs +41 -3
- package/src/list.mjs +47 -2
- package/src/mcp-server.mjs +21 -2
- package/src/stats.mjs +27 -4
- package/src/store/remote.mjs +22 -4
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
|
@@ -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
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
//
|
|
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(
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
:
|
|
175
|
+
: await buildSection(local, true);
|
|
90
176
|
|
|
91
177
|
const remoteAvailable = !remoteDenied && remote.usable();
|
|
92
178
|
const remoteSection = remoteAvailable
|
|
93
|
-
?
|
|
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 },
|
package/src/lessons-view.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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})` }
|
package/src/mcp-server.mjs
CHANGED
|
@@ -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: {
|
|
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: {
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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})` }
|
package/src/store/remote.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 } = {}) {
|