@lorekit/cli 1.33.4 → 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/README.md +4 -1
- package/bin/lorekit.mjs +12 -7
- package/package.json +1 -1
- package/skill/lorekit-groom/SKILL.md +36 -4
- package/skill/lorekit-groom/rules/grooming-pass.md +28 -3
- package/src/dedupe.mjs +103 -10
- package/src/lessons-view.mjs +240 -0
- package/src/lint.mjs +44 -6
- 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/README.md
CHANGED
|
@@ -381,7 +381,10 @@ lorekit lint --json # { total, offline, remote } structured findings
|
|
|
381
381
|
|
|
382
382
|
Rules: **empty-value** (blank/whitespace-only body), **short-value** (a non-empty
|
|
383
383
|
body below a small length threshold), **untrimmed-value** (real content with
|
|
384
|
-
surrounding whitespace), **empty-key** (blank key),
|
|
384
|
+
surrounding whitespace), **empty-key** (blank key), **volatile-key** (the key
|
|
385
|
+
carries a per-sighting identifier — a run of 6+ digits such as a GitHub comment
|
|
386
|
+
id, or a `pr<n>` / `issue<n>` segment — so it never collides, never dedups, and
|
|
387
|
+
freezes `seen_count` at 1), and **malformed-scope** (e.g.
|
|
385
388
|
a single `:` where `::` is expected). `lint` **exits non-zero (1) when any issue
|
|
386
389
|
is found**, so it is usable as a CI gate (`lorekit lint || exit 1`); a clean run —
|
|
387
390
|
or one where only a store is unavailable — exits 0. The pure rule predicates live
|
package/bin/lorekit.mjs
CHANGED
|
@@ -78,8 +78,9 @@ ${c.bold('Commands')}
|
|
|
78
78
|
(resolve) hierarchy and mark, per key, which scope's memory WINS and which are
|
|
79
79
|
shadowed — the real hook-resolution order. --json, --scope <s>.
|
|
80
80
|
lint Flag low-quality memories (empty/short/untrimmed value, empty key,
|
|
81
|
-
malformed scope) across the applicable scopes and
|
|
82
|
-
non-zero when issues are found (CI gate).
|
|
81
|
+
volatile key, malformed scope) across the applicable scopes and
|
|
82
|
+
both stores. Exits non-zero when issues are found (CI gate).
|
|
83
|
+
--json, --scope <s>.
|
|
83
84
|
dedupe Find likely-duplicate memories via a zero-dep word-overlap HEURISTIC
|
|
84
85
|
(Jaccard >= threshold, not semantic), grouped into clusters per
|
|
85
86
|
store. --json, --scope <s>, --threshold <0..1>.
|
|
@@ -495,10 +496,12 @@ ${c.bold('Usage')}
|
|
|
495
496
|
|
|
496
497
|
Checks every memory for the current directory's scopes (project/branch/repo/
|
|
497
498
|
global), across both stores, against a small set of quality rules: empty or
|
|
498
|
-
whitespace-only value, suspiciously short value, untrimmed value, empty key,
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
499
|
+
whitespace-only value, suspiciously short value, untrimmed value, empty key, a
|
|
500
|
+
volatile per-sighting identifier in the key (a run of 6+ digits, or a \`pr<n>\` /
|
|
501
|
+
\`issue<n>\` reference), and malformed scope (e.g. a single \`:\` where \`::\` is
|
|
502
|
+
expected). Each finding names the rule it violated. Exits NON-ZERO when any issue
|
|
503
|
+
is found, so it works as a CI gate; a clean run — or one where only a store is
|
|
504
|
+
unavailable — exits 0.
|
|
502
505
|
|
|
503
506
|
${c.bold('Options')}
|
|
504
507
|
-d, --dir <path> Target project root (default: current directory)
|
|
@@ -633,6 +636,8 @@ const KNOWN_FLAGS = [
|
|
|
633
636
|
'value', 'tags', 'source-agent', 'trigger', 'kind', 'host', 'ttl-days', 'clear-ttl', 'org', 'remote', 'local',
|
|
634
637
|
'link', 'base', 'q', 'owner', 'range', 'view', 'archived',
|
|
635
638
|
'origin-repo', 'origin-branch', 'origin-commit', 'origin-pr', 'no-origin',
|
|
639
|
+
// Scale-aware survey flags
|
|
640
|
+
'all', 'max', 'since', 'until', 'key-prefix',
|
|
636
641
|
];
|
|
637
642
|
|
|
638
643
|
// Commands that write to disk / talk to the network on a human's behalf. These
|
|
@@ -657,7 +662,7 @@ async function main() {
|
|
|
657
662
|
const argv = process.argv.slice(2);
|
|
658
663
|
const args = parseArgs(argv, {
|
|
659
664
|
aliases: { d: 'dir', e: 'endpoint', t: 'token', y: 'yes', h: 'help', v: 'version' },
|
|
660
|
-
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'],
|
|
661
666
|
known: KNOWN_FLAGS,
|
|
662
667
|
});
|
|
663
668
|
|
package/package.json
CHANGED
|
@@ -89,8 +89,8 @@ The short version is six phases; the first four are pure analysis.
|
|
|
89
89
|
`lorekit scopes` (store-wide inventory: every scope with its lesson count)
|
|
90
90
|
show where the mass is. Pick the noisiest scope to start.
|
|
91
91
|
2. **Lint** — `lorekit lint --json` flags structurally bad lessons (empty /
|
|
92
|
-
whitespace / suspiciously short / untrimmed values, empty keys,
|
|
93
|
-
scopes). These are the cheapest wins: fix or drop them first.
|
|
92
|
+
whitespace / suspiciously short / untrimmed values, empty keys, volatile
|
|
93
|
+
keys, malformed scopes). These are the cheapest wins: fix or drop them first.
|
|
94
94
|
3. **Dedupe** — `lorekit dedupe --json` clusters near-duplicate lessons. Start at
|
|
95
95
|
a high `--threshold` (e.g. `0.85`) for confident duplicates, then lower it to
|
|
96
96
|
surface looser paraphrases. Use `lorekit show <scope::key>` to read each
|
|
@@ -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
|
|
|
@@ -38,6 +50,11 @@ Findings are structural, not semantic — each names its rule:
|
|
|
38
50
|
- **empty-value / short-value / untrimmed-value** — the lesson carries little or
|
|
39
51
|
no signal, or has stray leading/trailing whitespace.
|
|
40
52
|
- **empty-key** — no key to address it by.
|
|
53
|
+
- **volatile-key** — the key carries a per-sighting identifier (a run of 6+
|
|
54
|
+
digits such as a GitHub comment id, or a `pr<n>` / `issue<n>` segment), so it
|
|
55
|
+
never collides with a later write, never dedups, and leaves `seen_count`
|
|
56
|
+
frozen at 1. Re-key it onto the structural pattern and move the identifier
|
|
57
|
+
into the body.
|
|
41
58
|
- **malformed-scope** — the scope string is invalid.
|
|
42
59
|
|
|
43
60
|
These are the cheapest wins and the least controversial, so clear them first.
|
|
@@ -51,6 +68,9 @@ makes it a clean CI gate — a passing `lint` is your Phase 6 proof.
|
|
|
51
68
|
|
|
52
69
|
```bash
|
|
53
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
|
|
54
74
|
```
|
|
55
75
|
|
|
56
76
|
Each cluster is a set of lessons whose values overlap heavily by word tokens.
|
|
@@ -59,6 +79,11 @@ Start high (`0.85`) to see the confident duplicates, then re-run lower (`0.75`,
|
|
|
59
79
|
clusters are coincidental overlaps rather than true duplicates, so read more
|
|
60
80
|
carefully.
|
|
61
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
|
+
|
|
62
87
|
For every cluster you intend to act on, read the members in full first:
|
|
63
88
|
|
|
64
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,
|
|
@@ -271,6 +272,41 @@ export const LINT_RULES = {
|
|
|
271
272
|
return v.trim() && v !== v.trim() ? 'value has leading/trailing whitespace' : null;
|
|
272
273
|
},
|
|
273
274
|
'empty-key': (e) => (String(e.key ?? '').trim() ? null : 'key is empty or whitespace-only'),
|
|
275
|
+
// A key carrying a per-sighting identifier (a comment id, a PR/issue number) is
|
|
276
|
+
// unique forever, so it never collides, so the upsert never dedups it, so
|
|
277
|
+
// `seen_count` stays frozen at 1 and the memory can never reach a recurrence
|
|
278
|
+
// threshold — a write-only record. Detection is deliberately conservative:
|
|
279
|
+
// • a run of 6+ digits (a GitHub comment id is ~10; `sha256`, `oauth2`,
|
|
280
|
+
// `wcag22`, and semantic versions are all shorter runs);
|
|
281
|
+
// • a `pr<n>` / `issue<n>` reference — the number joined by nothing, `-`, or
|
|
282
|
+
// `_` — delimited by `:`, `-`, `_`, `/`, or a string boundary, so mid-word
|
|
283
|
+
// digits (`oauth2`) never match.
|
|
284
|
+
// `volatileKeyAllow` is an embedder/test knob mirroring `short-value`'s
|
|
285
|
+
// `minValueLen` precedent — a list of substrings that exempt a key. There is
|
|
286
|
+
// no config key and no per-entry marker.
|
|
287
|
+
'volatile-key': (e, { volatileKeyAllow = [] } = {}) => {
|
|
288
|
+
const key = String(e.key ?? '');
|
|
289
|
+
if (!key.trim()) return null; // an empty key is `empty-key`'s to report.
|
|
290
|
+
// Tolerate a bare string as well as a list, so a caller passing
|
|
291
|
+
// `{ volatileKeyAllow: 'lorekit-231' }` does not silently iterate characters.
|
|
292
|
+
const allowList = Array.isArray(volatileKeyAllow) ? volatileKeyAllow : [volatileKeyAllow];
|
|
293
|
+
for (const allow of allowList) {
|
|
294
|
+
if (allow && key.includes(String(allow))) return null;
|
|
295
|
+
}
|
|
296
|
+
const digitRun = key.match(/\d{6,}/);
|
|
297
|
+
if (digitRun) {
|
|
298
|
+
return `key contains a volatile per-sighting identifier: '${digitRun[0]}' (a run of ${digitRun[0].length} digits)`;
|
|
299
|
+
}
|
|
300
|
+
// Boundary-anchored rather than split-then-match: splitting on `-` would
|
|
301
|
+
// separate `pr` from `231` and `pr-231` would slip through. The reference
|
|
302
|
+
// must start at a boundary (`:`, `-`, `_`, `/`, or the string start) and end
|
|
303
|
+
// at one, so `oauth2`/`sha256`/`wcag22` still never match.
|
|
304
|
+
const reference = key.match(/(?:^|[:\-_/])((?:pr|issue)[-_]?\d+)(?=$|[:\-_/])/i);
|
|
305
|
+
if (reference) {
|
|
306
|
+
return `key contains a volatile per-sighting identifier: '${reference[1]}' (a pr/issue number segment)`;
|
|
307
|
+
}
|
|
308
|
+
return null;
|
|
309
|
+
},
|
|
274
310
|
'malformed-scope': (e) => {
|
|
275
311
|
const reason = scopeIssue(e.scope);
|
|
276
312
|
return reason ? `malformed scope: ${reason}` : null;
|
|
@@ -398,6 +434,8 @@ export function clusterDuplicates(entries = [], threshold = 0.8) {
|
|
|
398
434
|
// `store.list({scope})` contract. Returns ordered per-scope groups plus a total
|
|
399
435
|
// — a per-scope read failure is captured on the group, never thrown, so one bad
|
|
400
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.
|
|
401
439
|
export async function gather(store, scopes, filters = {}) {
|
|
402
440
|
// Parse the taxonomy filters into value sets. `filters` is passed to the store
|
|
403
441
|
// too (the remote narrows server-side); we ALSO post-filter the normalized
|
|
@@ -433,6 +471,208 @@ export async function gather(store, scopes, filters = {}) {
|
|
|
433
471
|
return { groups, total };
|
|
434
472
|
}
|
|
435
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
|
+
|
|
436
676
|
// Render one section (Offline or Remote). `section` is either
|
|
437
677
|
// { available:false, reason } → a graceful note, or
|
|
438
678
|
// { available:true, groups, total } → grouped lessons.
|
package/src/lint.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// `lorekit lint` — flag low-quality lessons across the applicable scopes and
|
|
2
2
|
// both stores. Each finding names the rule it violated (empty/whitespace value,
|
|
3
|
-
// suspiciously short value, untrimmed value, empty key,
|
|
4
|
-
// rules are pure predicates in `lessons-view.mjs` (`LINT_RULES` /
|
|
5
|
-
// each independently unit-tested.
|
|
3
|
+
// suspiciously short value, untrimmed value, empty key, volatile key, malformed
|
|
4
|
+
// scope). The rules are pure predicates in `lessons-view.mjs` (`LINT_RULES` /
|
|
5
|
+
// `lintEntry`), each independently unit-tested.
|
|
6
6
|
//
|
|
7
7
|
// Exit convention: `lint` exits NON-ZERO (1) when any finding exists, so it is
|
|
8
8
|
// usable as a CI gate (`lorekit lint || fail`); a clean run — or a run where the
|
|
@@ -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 } = {}) {
|