@opum-ai/lore 0.1.0 → 0.1.1
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 +33 -22
- package/bin/lore.cjs +35 -7
- package/package.json +17 -17
- package/src/adapters/backlog.ts +0 -1084
- package/src/adapters/git.ts +0 -221
- package/src/cli.ts +0 -667
- package/src/commands/agent.ts +0 -301
- package/src/commands/agents.ts +0 -302
- package/src/commands/args.ts +0 -209
- package/src/commands/changed.ts +0 -70
- package/src/commands/check.ts +0 -1031
- package/src/commands/codex-bridge.ts +0 -49
- package/src/commands/concurrency.ts +0 -48
- package/src/commands/context.ts +0 -292
- package/src/commands/discover.ts +0 -89
- package/src/commands/explorer.ts +0 -253
- package/src/commands/export.ts +0 -93
- package/src/commands/fswrite.ts +0 -928
- package/src/commands/graph.ts +0 -291
- package/src/commands/help.ts +0 -151
- package/src/commands/impact.ts +0 -59
- package/src/commands/init.ts +0 -583
- package/src/commands/instructions.ts +0 -91
- package/src/commands/link.ts +0 -929
- package/src/commands/new.ts +0 -476
- package/src/commands/orphans.ts +0 -457
- package/src/commands/path.ts +0 -67
- package/src/commands/provenance.ts +0 -68
- package/src/commands/query.ts +0 -312
- package/src/commands/reconcile-shared.ts +0 -280
- package/src/commands/rename.ts +0 -585
- package/src/commands/replace.ts +0 -320
- package/src/commands/scaffold.ts +0 -346
- package/src/commands/schema.ts +0 -293
- package/src/commands/snapshot.ts +0 -130
- package/src/commands/supersede.ts +0 -400
- package/src/commands/sync.ts +0 -371
- package/src/commands/tasks.ts +0 -271
- package/src/commands/traversal.ts +0 -151
- package/src/commands/validate.ts +0 -226
- package/src/config.ts +0 -598
- package/src/core/agent-bridge.ts +0 -287
- package/src/core/agent-context.ts +0 -498
- package/src/core/agent-profile.ts +0 -447
- package/src/core/bundle.ts +0 -893
- package/src/core/check.ts +0 -853
- package/src/core/codex-bridge.ts +0 -100
- package/src/core/concept.ts +0 -597
- package/src/core/consumer-scaffold.ts +0 -433
- package/src/core/context.ts +0 -271
- package/src/core/explorer-contract.ts +0 -441
- package/src/core/explorer-qualification.ts +0 -58
- package/src/core/explorer.ts +0 -518
- package/src/core/finding.ts +0 -31
- package/src/core/graph.ts +0 -201
- package/src/core/indexes.ts +0 -436
- package/src/core/instructions.ts +0 -209
- package/src/core/ladybug-driver.ts +0 -1795
- package/src/core/ladybug-lifecycle.ts +0 -1178
- package/src/core/ladybug-native.ts +0 -95
- package/src/core/ladybug-source.ts +0 -667
- package/src/core/links.ts +0 -681
- package/src/core/log.ts +0 -253
- package/src/core/managed-block.ts +0 -540
- package/src/core/manifest.ts +0 -718
- package/src/core/order.ts +0 -13
- package/src/core/profile.ts +0 -1007
- package/src/core/projection.ts +0 -195
- package/src/core/query.ts +0 -542
- package/src/core/reconcile.ts +0 -236
- package/src/core/replace.ts +0 -419
- package/src/core/retrieval.ts +0 -213
- package/src/core/rewrite.ts +0 -940
- package/src/core/scaffold.ts +0 -255
- package/src/core/schema.ts +0 -366
- package/src/core/snapshot-runtime.ts +0 -52
- package/src/core/snapshot-store.ts +0 -287
- package/src/core/snapshot.ts +0 -711
- package/src/core/template.ts +0 -429
- package/src/core/traversal.ts +0 -487
- package/src/core/validate.ts +0 -517
- package/src/core/workspace-contract.ts +0 -473
- package/src/core/workspace-projection.ts +0 -365
- package/src/core/workspace-retrieval.ts +0 -196
- package/src/core/workspace-source.ts +0 -174
- package/src/errors.ts +0 -697
- package/src/meta.ts +0 -7
- package/src/output.ts +0 -589
- package/src/scripts/upstream-backlog-watch.ts +0 -288
- package/src/state.ts +0 -390
package/src/core/query.ts
DELETED
|
@@ -1,542 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* query.ts — in-memory navigation over the bundle graph.
|
|
3
|
-
*
|
|
4
|
-
* This is the shared navigation core every read-only "where does this connect"
|
|
5
|
-
* command sits on. It hosts two primitives over the same {@link BundleGraph}
|
|
6
|
-
* substrate:
|
|
7
|
-
*
|
|
8
|
-
* - {@link subgraph} — the depth-bounded neighborhood traversal `lore graph` uses
|
|
9
|
-
* to root its export at one concept and `lore context` (LORE-34) reuses to gather
|
|
10
|
-
* a target's neighbors before compacting them to a token budget.
|
|
11
|
-
* - {@link query} — the BM25-style in-memory full-text search + frontmatter-field
|
|
12
|
-
* filters behind `lore query` (LORE-33). No vectors, RAG, or chunking (ADR-0015):
|
|
13
|
-
* a lightweight lexical index built fresh from the loaded graph each call.
|
|
14
|
-
*
|
|
15
|
-
* Keeping both in one module is deliberate — they are the two halves of "navigate
|
|
16
|
-
* the bundle" (structural reach and lexical relevance), and a consumer that has the
|
|
17
|
-
* graph already has both.
|
|
18
|
-
*
|
|
19
|
-
* Like the rest of `core/` (design §2.1) this module is pure: it reads an
|
|
20
|
-
* already-loaded {@link BundleGraph} and returns plain data or throws a
|
|
21
|
-
* {@link LoreError}; it never touches the filesystem, prints, or reads flags.
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
import { singleLine } from "../errors";
|
|
25
|
-
import { type BundleGraph, conceptNotInBundle, type Edge, frontmatterScalar } from "./bundle";
|
|
26
|
-
import type { Concept } from "./concept";
|
|
27
|
-
import { compareCodeUnits } from "./order";
|
|
28
|
-
import type { WorkspaceRecordProvenance, WorkspaceResultScope } from "./workspace-contract";
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Collect the ids of every concept within `maxDepth` link-hops of `rootId`,
|
|
32
|
-
* including the root itself — the concept's *neighborhood*.
|
|
33
|
-
*
|
|
34
|
-
* Traversal is **undirected**: an edge `A → B` makes `A` and `B` mutual
|
|
35
|
-
* neighbors, because orientation is what a reader wants from "show me around this
|
|
36
|
-
* concept" — both what it points at (its `specs`/links) and what points back at
|
|
37
|
-
* it (who supersedes or cites it). Only **resolved** edges connect: a dangling
|
|
38
|
-
* edge (`to: null`, a broken reference) has no target node to step to, so it
|
|
39
|
-
* never extends reach — surfacing the break is `lore check`'s job, and a node's
|
|
40
|
-
* own dangling edges are still reported by the export layer once the node is in.
|
|
41
|
-
*
|
|
42
|
-
* `maxDepth` bounds the radius in hops from the root:
|
|
43
|
-
*
|
|
44
|
-
* - `0` → just the root (no neighbors).
|
|
45
|
-
* - `n` → the root plus everything reachable in ≤ `n` hops.
|
|
46
|
-
* - `Infinity` → the entire connected component the root sits in (how `lore
|
|
47
|
-
* graph <id>` behaves with no `--depth`).
|
|
48
|
-
*
|
|
49
|
-
* The walk is a breadth-first level expansion over a deduplicated adjacency
|
|
50
|
-
* built from {@link BundleGraph.edges} (itself deterministic), and the returned
|
|
51
|
-
* set iterates in **discovery order** (root first, then each level) — so the
|
|
52
|
-
* same bundle and root always yield the same set in the same order, with no
|
|
53
|
-
* dependence on edge multiplicity or input order. A link cycle (`A→B→A`) or a
|
|
54
|
-
* self-link is harmless: a visited node is never re-enqueued.
|
|
55
|
-
*
|
|
56
|
-
* @throws LoreError `not_found` (exit 3) via {@link conceptNotInBundle} when
|
|
57
|
-
* `rootId` names no concept in the bundle.
|
|
58
|
-
*/
|
|
59
|
-
export function subgraph(graph: BundleGraph, rootId: string, maxDepth: number): Set<string> {
|
|
60
|
-
if (!graph.concepts.has(rootId)) {
|
|
61
|
-
throw conceptNotInBundle(rootId);
|
|
62
|
-
}
|
|
63
|
-
// A root-only radius never reads the adjacency, so don't pay to build the whole
|
|
64
|
-
// O(E) index for it.
|
|
65
|
-
if (maxDepth <= 0) {
|
|
66
|
-
return new Set([rootId]);
|
|
67
|
-
}
|
|
68
|
-
const adjacency = graph.neighbors === undefined ? buildAdjacency(graph.edges) : undefined;
|
|
69
|
-
const neighbors = graph.neighbors ?? ((id: string) => adjacency?.get(id) ?? EMPTY);
|
|
70
|
-
const visited = new Set<string>([rootId]);
|
|
71
|
-
let frontier: string[] = [rootId];
|
|
72
|
-
// Level-by-level BFS: each iteration expands one hop. `maxDepth` is an upper
|
|
73
|
-
// bound on iterations, so `Infinity` simply runs until the frontier drains.
|
|
74
|
-
for (let depth = 0; depth < maxDepth && frontier.length > 0; depth++) {
|
|
75
|
-
const next: string[] = [];
|
|
76
|
-
for (const id of frontier) {
|
|
77
|
-
for (const neighbor of neighbors(id)) {
|
|
78
|
-
if (!visited.has(neighbor)) {
|
|
79
|
-
visited.add(neighbor);
|
|
80
|
-
next.push(neighbor);
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
frontier = next;
|
|
85
|
-
}
|
|
86
|
-
return visited;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/** Shared empty neighbor list for nodes with no resolved edges (avoids a per-lookup allocation). */
|
|
90
|
-
const EMPTY: readonly string[] = [];
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Build the undirected adjacency map from the bundle's edge list. Each resolved
|
|
94
|
-
* edge contributes a symmetric neighbor pair; a dangling edge (`to: null`) and a
|
|
95
|
-
* self-link (`from === to`) contribute nothing (the former has no target, the
|
|
96
|
-
* latter no new reach). A {@link Set} per node deduplicates parallel edges (two
|
|
97
|
-
* concepts linked both by body and by a frontmatter ref are one neighbor), and
|
|
98
|
-
* because {@link BundleGraph.edges} is already in deterministic order, insertion
|
|
99
|
-
* order — hence later iteration order — is deterministic too.
|
|
100
|
-
*/
|
|
101
|
-
function buildAdjacency(edges: readonly Edge[]): Map<string, Set<string>> {
|
|
102
|
-
const adjacency = new Map<string, Set<string>>();
|
|
103
|
-
const connect = (a: string, b: string): void => {
|
|
104
|
-
let neighbors = adjacency.get(a);
|
|
105
|
-
if (neighbors === undefined) {
|
|
106
|
-
neighbors = new Set<string>();
|
|
107
|
-
adjacency.set(a, neighbors);
|
|
108
|
-
}
|
|
109
|
-
neighbors.add(b);
|
|
110
|
-
};
|
|
111
|
-
for (const edge of edges) {
|
|
112
|
-
if (edge.to === null || edge.to === edge.from) {
|
|
113
|
-
continue;
|
|
114
|
-
}
|
|
115
|
-
connect(edge.from, edge.to);
|
|
116
|
-
connect(edge.to, edge.from);
|
|
117
|
-
}
|
|
118
|
-
return adjacency;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// ── Full-text query (lore query, LORE-33) ───────────────────────────────────────
|
|
122
|
-
|
|
123
|
-
/** A `key=value` frontmatter-field filter (`lore query --field <key>=<value>`). */
|
|
124
|
-
export interface FieldFilter {
|
|
125
|
-
/** The frontmatter key to test (trimmed; an empty key is rejected at the command boundary). */
|
|
126
|
-
readonly key: string;
|
|
127
|
-
/** The value the field must equal (case-insensitively), or — for a list field — contain. */
|
|
128
|
-
readonly value: string;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/** The inputs to a {@link query}: an optional search text plus the frontmatter filters and the result cap. */
|
|
132
|
-
export interface QueryOptions {
|
|
133
|
-
/**
|
|
134
|
-
* The free-text search. Trimmed; an empty/whitespace-only or absent value means
|
|
135
|
-
* **no text query** (the result is the filtered set, unranked). A non-empty text
|
|
136
|
-
* also acts as a relevance filter — only concepts containing at least one of its
|
|
137
|
-
* terms are returned.
|
|
138
|
-
*/
|
|
139
|
-
readonly text?: string;
|
|
140
|
-
/** `--type`: keep only concepts whose `type` equals this (case-insensitively). */
|
|
141
|
-
readonly type?: string;
|
|
142
|
-
/** `--tag` (repeatable): keep only concepts whose `tags` contain **every** listed tag (case-insensitively). */
|
|
143
|
-
readonly tags?: readonly string[];
|
|
144
|
-
/** `--status`: keep only concepts whose `status` frontmatter equals this (case-insensitively). */
|
|
145
|
-
readonly status?: string;
|
|
146
|
-
/** `--field` (repeatable): keep only concepts matching **every** `key=value` field test. */
|
|
147
|
-
readonly fields?: readonly FieldFilter[];
|
|
148
|
-
/** `--limit`: the maximum number of hits to return. Defaults to {@link DEFAULT_QUERY_LIMIT}. */
|
|
149
|
-
readonly limit?: number;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/** One ranked search hit (cli-surface §query `query.results`). */
|
|
153
|
-
export interface QueryHit {
|
|
154
|
-
/** The concept id. */
|
|
155
|
-
readonly id: string;
|
|
156
|
-
/** The concept's resolved `type`. */
|
|
157
|
-
readonly type: string;
|
|
158
|
-
/** The concept's `title` frontmatter, when present and a non-empty scalar; omitted otherwise. */
|
|
159
|
-
readonly title?: string;
|
|
160
|
-
/**
|
|
161
|
-
* A one-line snippet: the concept's `summary` frontmatter, falling back to its
|
|
162
|
-
* `title`, collapsed to a single trimmed line. Omitted when the concept carries
|
|
163
|
-
* neither (the id/type are still the structural signal).
|
|
164
|
-
*/
|
|
165
|
-
readonly snippet?: string;
|
|
166
|
-
/**
|
|
167
|
-
* The BM25 relevance score (higher is more relevant). `0` for every hit on a
|
|
168
|
-
* **filters-only** query (no text to rank by) — the hits are then ordered by id.
|
|
169
|
-
*/
|
|
170
|
-
readonly score: number;
|
|
171
|
-
/** Complete locator-free provenance in explicit workspace mode. */
|
|
172
|
-
readonly provenance?: WorkspaceRecordProvenance;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/** The `query.results` payload: the ranked hits (already capped) plus the bounded-output accounting. */
|
|
176
|
-
export interface QueryResult {
|
|
177
|
-
/** The normalized text query, when one drove the ranking; omitted on a filters-only query. */
|
|
178
|
-
readonly query?: string;
|
|
179
|
-
/** The hits, capped to `limit`: score-descending then id-ascending under a text query, else id-ascending. */
|
|
180
|
-
readonly hits: readonly QueryHit[];
|
|
181
|
-
/** The total number of concepts that matched (filters ∧ text relevance) before the `limit` cap. */
|
|
182
|
-
readonly total: number;
|
|
183
|
-
/** The number actually returned (`shown <= total`). */
|
|
184
|
-
readonly shown: number;
|
|
185
|
-
/** `true` when the cap dropped matches (`shown < total`) — an honest bounded-output signal, never a silent cut. */
|
|
186
|
-
readonly truncated: boolean;
|
|
187
|
-
/** Explicit selected workspace scope; absent for repository-local output. */
|
|
188
|
-
readonly workspace?: WorkspaceResultScope;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/** The default `--limit` when none is given — a bounded result so a broad query never floods stdout (cli-surface §query). */
|
|
192
|
-
export const DEFAULT_QUERY_LIMIT = 20;
|
|
193
|
-
|
|
194
|
-
/** BM25 term-frequency saturation parameter `k1` (the standard 1.5). */
|
|
195
|
-
const BM25_K1 = 1.5;
|
|
196
|
-
/** BM25 length-normalization parameter `b` (the standard 0.75). */
|
|
197
|
-
const BM25_B = 0.75;
|
|
198
|
-
|
|
199
|
-
/**
|
|
200
|
-
* Search the bundle: keep the concepts matching every frontmatter filter, rank them
|
|
201
|
-
* by BM25 relevance to `options.text` (when given), and return the top `limit` hits
|
|
202
|
-
* with a bounded-output signal.
|
|
203
|
-
*
|
|
204
|
-
* **Filtering** (AC#1) is case-insensitive across `--type`/`--tag`/`--status`/`--field`;
|
|
205
|
-
* a concept must satisfy *all* provided filters. **Ranking**: with a text query, a
|
|
206
|
-
* lexical BM25 index is built fresh from the whole bundle (id, `title`, `summary`,
|
|
207
|
-
* `description`, `tags`, and body tokens) and each filtered concept is scored;
|
|
208
|
-
* concepts containing **no** query term score 0 and are dropped, so the text behaves
|
|
209
|
-
* as a relevance filter as well as a sort key. Ties (and a filters-only query, where
|
|
210
|
-
* every score is 0) break by ascending id, so the order is fully deterministic.
|
|
211
|
-
*
|
|
212
|
-
* **Bounded output** (AC#2): `total` is the full match count and `hits` is its first
|
|
213
|
-
* `limit`, with `truncated` set when the cap dropped matches — the command renders the
|
|
214
|
-
* §3 truncation line with a narrow-it hint. No vectors, RAG, or chunking (ADR-0015):
|
|
215
|
-
* this is a deterministic lexical index, not a semantic one. Pure — reads the graph,
|
|
216
|
-
* returns plain data, never touches the filesystem or flags.
|
|
217
|
-
*/
|
|
218
|
-
export function query(graph: BundleGraph, options: QueryOptions = {}): QueryResult {
|
|
219
|
-
return queryWithBm25Index(graph, options);
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
/**
|
|
223
|
-
* Search with a caller-supplied deterministic BM25 index.
|
|
224
|
-
*
|
|
225
|
-
* The persistent Ladybug reader uses this boundary after fetching only postings
|
|
226
|
-
* for the requested terms. Reference callers omit `index` and retain the exact
|
|
227
|
-
* in-memory behavior.
|
|
228
|
-
*/
|
|
229
|
-
export function queryWithBm25Index(graph: BundleGraph, options: QueryOptions = {}, index?: Bm25Index): QueryResult {
|
|
230
|
-
const limit = options.limit ?? DEFAULT_QUERY_LIMIT;
|
|
231
|
-
const normalizedText = (options.text ?? "").trim();
|
|
232
|
-
const queryTerms = [...new Set(tokenizeQueryText(normalizedText))];
|
|
233
|
-
// `hasText` keys off the *tokenized* terms, not the raw string: a non-empty text
|
|
234
|
-
// that yields no searchable term (punctuation/separators only, e.g. `"%%%"`) is
|
|
235
|
-
// treated as a filters-only query rather than a ranked one that would score every
|
|
236
|
-
// concept 0 and silently drop the frontmatter filters passed alongside it.
|
|
237
|
-
const hasText = queryTerms.length > 0;
|
|
238
|
-
|
|
239
|
-
// Iterating the id-ordered `concepts` map keeps the filtered list ascending by id,
|
|
240
|
-
// which is already the filters-only order and the tie-break order under ranking.
|
|
241
|
-
const matched: Array<{ concept: Concept; score: number }> = [];
|
|
242
|
-
// IDF is a per-term, per-corpus constant — compute it once here, not once per scored
|
|
243
|
-
// document, so a query over a large filtered set does not recompute the same logs.
|
|
244
|
-
const activeIndex = hasText ? (index ?? buildBm25Index(graph)) : undefined;
|
|
245
|
-
const idf = activeIndex !== undefined ? idfForTerms(activeIndex, queryTerms) : undefined;
|
|
246
|
-
for (const concept of graph.concepts.values()) {
|
|
247
|
-
if (!matchesFilters(concept, options)) {
|
|
248
|
-
continue;
|
|
249
|
-
}
|
|
250
|
-
if (activeIndex === undefined || idf === undefined) {
|
|
251
|
-
matched.push({ concept, score: 0 });
|
|
252
|
-
continue;
|
|
253
|
-
}
|
|
254
|
-
// A text query also filters: a concept with none of its terms scores 0 and is dropped.
|
|
255
|
-
const score = scoreBm25(activeIndex, idf, concept.id, queryTerms);
|
|
256
|
-
if (score > 0) {
|
|
257
|
-
matched.push({ concept, score });
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
if (hasText) {
|
|
262
|
-
matched.sort((a, b) => b.score - a.score || compareCodeUnits(a.concept.id, b.concept.id));
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
const total = matched.length;
|
|
266
|
-
const shown = Math.min(total, limit);
|
|
267
|
-
const hits = matched.slice(0, shown).map(({ concept, score }) => toHit(concept, score));
|
|
268
|
-
return {
|
|
269
|
-
...(hasText ? { query: normalizedText } : {}),
|
|
270
|
-
hits,
|
|
271
|
-
total,
|
|
272
|
-
shown,
|
|
273
|
-
truncated: shown < total,
|
|
274
|
-
};
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
/** Shape one matched concept into its {@link QueryHit} (title verbatim; snippet = `summary` → `title` → none). */
|
|
278
|
-
function toHit(concept: Concept, score: number): QueryHit {
|
|
279
|
-
const title = frontmatterScalar(concept.frontmatter.title);
|
|
280
|
-
const snippet = oneLine(frontmatterScalar(concept.frontmatter.summary) ?? title);
|
|
281
|
-
return {
|
|
282
|
-
id: concept.id,
|
|
283
|
-
type: concept.type,
|
|
284
|
-
...(title !== undefined ? { title } : {}),
|
|
285
|
-
...(snippet !== undefined ? { snippet } : {}),
|
|
286
|
-
score,
|
|
287
|
-
};
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
/**
|
|
291
|
-
* Whether `concept` satisfies every provided filter — the AC#1 type/tag/status/field
|
|
292
|
-
* gate. `--type` compares the resolved `type` mirror; `--status` and each `--tag` are
|
|
293
|
-
* just the general `--field` test against the `status`/`tags` keys (one matcher, so
|
|
294
|
-
* the scalar-vs-list and case rules can never diverge between them and an arbitrary
|
|
295
|
-
* `--field`). All comparisons fold case.
|
|
296
|
-
*/
|
|
297
|
-
function matchesFilters(concept: Concept, options: QueryOptions): boolean {
|
|
298
|
-
if (options.type !== undefined && !equalsFold(concept.type, options.type)) {
|
|
299
|
-
return false;
|
|
300
|
-
}
|
|
301
|
-
if (options.status !== undefined && !matchesField(concept, { key: "status", value: options.status })) {
|
|
302
|
-
return false;
|
|
303
|
-
}
|
|
304
|
-
if (options.tags !== undefined) {
|
|
305
|
-
for (const want of options.tags) {
|
|
306
|
-
if (!matchesField(concept, { key: "tags", value: want })) {
|
|
307
|
-
return false;
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
if (options.fields !== undefined) {
|
|
312
|
-
for (const filter of options.fields) {
|
|
313
|
-
if (!matchesField(concept, filter)) {
|
|
314
|
-
return false;
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
return true;
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
/**
|
|
322
|
-
* Whether `concept`'s `filter.key` frontmatter matches `filter.value`: a list field
|
|
323
|
-
* matches when **any** element equals the value (case-insensitively), a scalar field
|
|
324
|
-
* when it equals the value.
|
|
325
|
-
*
|
|
326
|
-
* The **key** is resolved case-insensitively too — a `--field Status=…` finds a
|
|
327
|
-
* `status:` key — so the key and the value fold consistently (and consistently with
|
|
328
|
-
* `--type`/`--tag`/`--status`), instead of a verbatim-case key lookup silently
|
|
329
|
-
* missing. Only **own** enumerable keys are scanned (`Object.keys`), so an inherited
|
|
330
|
-
* `constructor`/`toString` can never satisfy a filter. A field with no scalar/list
|
|
331
|
-
* value, or no matching key, never matches.
|
|
332
|
-
*
|
|
333
|
-
* YAML keys are case-sensitive, so a concept can carry more than one case-variant of
|
|
334
|
-
* the same logical key (`Status: draft` alongside `status: done` — both survive
|
|
335
|
-
* frontmatter parsing as distinct own keys). Every such candidate key is checked, not
|
|
336
|
-
* only the first one enumeration happens to reach, so a match under *any*
|
|
337
|
-
* case-variant key succeeds regardless of author insertion order.
|
|
338
|
-
*/
|
|
339
|
-
function matchesField(concept: Concept, filter: FieldFilter): boolean {
|
|
340
|
-
const keys = Object.keys(concept.frontmatter).filter((candidate) => equalsFold(candidate, filter.key));
|
|
341
|
-
return keys.some((key) => {
|
|
342
|
-
const raw = concept.frontmatter[key];
|
|
343
|
-
if (Array.isArray(raw)) {
|
|
344
|
-
return raw.some((item) => {
|
|
345
|
-
const value = frontmatterScalar(item);
|
|
346
|
-
return value !== undefined && equalsFold(value, filter.value);
|
|
347
|
-
});
|
|
348
|
-
}
|
|
349
|
-
const value = frontmatterScalar(raw);
|
|
350
|
-
return value !== undefined && equalsFold(value, filter.value);
|
|
351
|
-
});
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
/** A concept's `tags` as a list of scalar strings (a bare string tag is treated as a one-element list; anything else is empty). */
|
|
355
|
-
function tagsOf(concept: Concept): string[] {
|
|
356
|
-
const raw = concept.frontmatter.tags;
|
|
357
|
-
const items = Array.isArray(raw) ? raw : [raw];
|
|
358
|
-
const tags: string[] = [];
|
|
359
|
-
for (const item of items) {
|
|
360
|
-
const value = frontmatterScalar(item);
|
|
361
|
-
if (value !== undefined) {
|
|
362
|
-
tags.push(value);
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
return tags;
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
/** Case-insensitive string equality — the single rule for every `lore query` filter comparison. */
|
|
369
|
-
function equalsFold(a: string, b: string): boolean {
|
|
370
|
-
return a.toLowerCase() === b.toLowerCase();
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
/**
|
|
374
|
-
* Collapse an already-coerced scalar ({@link frontmatterScalar}) to a single trimmed,
|
|
375
|
-
* non-empty line for a hit's snippet, or `undefined` — the same one-line compaction
|
|
376
|
-
* `lore context` applies to a neighbor's summary, so a concept's snippet reads
|
|
377
|
-
* identically from both commands.
|
|
378
|
-
*/
|
|
379
|
-
function oneLine(value: string | undefined): string | undefined {
|
|
380
|
-
if (value === undefined) {
|
|
381
|
-
return undefined;
|
|
382
|
-
}
|
|
383
|
-
const line = singleLine(value).trim();
|
|
384
|
-
return line === "" ? undefined : line;
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
// ── BM25 lexical index ───────────────────────────────────────────────────────────
|
|
388
|
-
|
|
389
|
-
/** One document's term frequencies and length (in tokens) within the BM25 index. */
|
|
390
|
-
export interface IndexedDoc {
|
|
391
|
-
/** Term → occurrence count in this document. */
|
|
392
|
-
readonly tf: ReadonlyMap<string, number>;
|
|
393
|
-
/** The document's length in tokens (the BM25 length-normalization input). */
|
|
394
|
-
readonly length: number;
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
/** A whole-bundle BM25 index: per-document term frequencies, document frequencies, the doc count, and the average length. */
|
|
398
|
-
export interface Bm25Index {
|
|
399
|
-
/** Indexed documents keyed by concept id. */
|
|
400
|
-
readonly docs: ReadonlyMap<string, IndexedDoc>;
|
|
401
|
-
/** Term → number of documents that contain it (for IDF). */
|
|
402
|
-
readonly df: ReadonlyMap<string, number>;
|
|
403
|
-
/** The number of indexed documents (`N`). */
|
|
404
|
-
readonly n: number;
|
|
405
|
-
/** The mean document length in tokens (`avgdl`); `0` only when there are no documents. */
|
|
406
|
-
readonly avgdl: number;
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
/** One arbitrary lexical record for consumers that share Lore's BM25 contract. */
|
|
410
|
-
export interface Bm25Record {
|
|
411
|
-
readonly id: string;
|
|
412
|
-
readonly text: string;
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
/** One arbitrary record's deterministic relevance score. */
|
|
416
|
-
export interface Bm25RecordScore {
|
|
417
|
-
readonly id: string;
|
|
418
|
-
readonly score: number;
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
/**
|
|
422
|
-
* Tokenize a string into lower-cased lexical terms: maximal runs of Unicode letters
|
|
423
|
-
* or digits, with everything else (punctuation, whitespace, path separators) a
|
|
424
|
-
* boundary. So `stories/bulk-archive` yields `["stories", "bulk", "archive"]`. This
|
|
425
|
-
* is a deterministic lexical split, not a real tokenizer — adequate for the
|
|
426
|
-
* lightweight in-memory search ADR-0015 calls for.
|
|
427
|
-
*/
|
|
428
|
-
export function tokenizeQueryText(text: string): string[] {
|
|
429
|
-
return text.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? [];
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
/** The searchable text of a concept: its id, the `title`/`summary`/`description`/`tags` scalars, and the body. */
|
|
433
|
-
export function searchableConceptText(concept: Concept): string {
|
|
434
|
-
return searchableConceptFields(concept).join(" ");
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
/** Searchable fields kept separate so persistent builders do not copy a large body merely to add separators. */
|
|
438
|
-
export function searchableConceptFields(concept: Concept): readonly string[] {
|
|
439
|
-
const fm = concept.frontmatter;
|
|
440
|
-
return [
|
|
441
|
-
concept.id,
|
|
442
|
-
frontmatterScalar(fm.title) ?? "",
|
|
443
|
-
frontmatterScalar(fm.summary) ?? "",
|
|
444
|
-
frontmatterScalar(fm.description) ?? "",
|
|
445
|
-
tagsOf(concept).join(" "),
|
|
446
|
-
concept.body,
|
|
447
|
-
];
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
/**
|
|
451
|
-
* Build the BM25 index over **every** concept in the bundle (so IDF reflects true
|
|
452
|
-
* corpus rarity regardless of which filters a query applies). Each concept's
|
|
453
|
-
* {@link searchableText} is tokenized once into its term frequencies; document
|
|
454
|
-
* frequencies and the average document length accumulate across the pass.
|
|
455
|
-
*/
|
|
456
|
-
export function buildBm25Index(graph: BundleGraph): Bm25Index {
|
|
457
|
-
return buildBm25RecordIndex(
|
|
458
|
-
[...graph.concepts.values()].map((concept) => ({ id: concept.id, text: searchableConceptText(concept) })),
|
|
459
|
-
);
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
/**
|
|
463
|
-
* Build the same deterministic BM25 index over caller-owned records. This is the
|
|
464
|
-
* reusable lexical seam profile section ranking needs; `lore query` delegates to
|
|
465
|
-
* it, so the two consumers cannot drift in tokenization, IDF, or saturation.
|
|
466
|
-
*/
|
|
467
|
-
export function buildBm25RecordIndex(records: readonly Bm25Record[]): Bm25Index {
|
|
468
|
-
const docs = new Map<string, IndexedDoc>();
|
|
469
|
-
const df = new Map<string, number>();
|
|
470
|
-
let totalLength = 0;
|
|
471
|
-
for (const record of records) {
|
|
472
|
-
if (docs.has(record.id)) {
|
|
473
|
-
throw new Error(`duplicate BM25 record id: ${record.id}`);
|
|
474
|
-
}
|
|
475
|
-
const tokens = tokenizeQueryText(record.text);
|
|
476
|
-
const tf = new Map<string, number>();
|
|
477
|
-
for (const token of tokens) {
|
|
478
|
-
tf.set(token, (tf.get(token) ?? 0) + 1);
|
|
479
|
-
}
|
|
480
|
-
for (const term of tf.keys()) {
|
|
481
|
-
df.set(term, (df.get(term) ?? 0) + 1);
|
|
482
|
-
}
|
|
483
|
-
docs.set(record.id, { tf, length: tokens.length });
|
|
484
|
-
totalLength += tokens.length;
|
|
485
|
-
}
|
|
486
|
-
const n = docs.size;
|
|
487
|
-
return { docs, df, n, avgdl: n === 0 ? 0 : totalLength / n };
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
/**
|
|
491
|
-
* Score arbitrary records against task text. Input order is retained in the
|
|
492
|
-
* returned array; callers apply their own stable domain tie-breaks. Punctuation-
|
|
493
|
-
* only tasks and all-zero corpora return zero scores so declaration-order
|
|
494
|
-
* fallback remains explicit at the caller.
|
|
495
|
-
*/
|
|
496
|
-
export function scoreBm25Records(records: readonly Bm25Record[], text: string): readonly Bm25RecordScore[] {
|
|
497
|
-
const terms = [...new Set(tokenizeQueryText(text.trim()))];
|
|
498
|
-
if (terms.length === 0) return records.map((record) => ({ id: record.id, score: 0 }));
|
|
499
|
-
const index = buildBm25RecordIndex(records);
|
|
500
|
-
const idf = idfForTerms(index, terms);
|
|
501
|
-
return records.map((record) => ({ id: record.id, score: scoreBm25(index, idf, record.id, terms) }));
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
/**
|
|
505
|
-
* The inverse-document-frequency of each query term — computed once per query, since
|
|
506
|
-
* IDF depends only on the corpus, not the document being scored. The always-non-negative
|
|
507
|
-
* variant `ln(1 + (N − n + 0.5)/(n + 0.5))`, so a term in most documents can never push
|
|
508
|
-
* a score negative.
|
|
509
|
-
*/
|
|
510
|
-
function idfForTerms(index: Bm25Index, terms: readonly string[]): ReadonlyMap<string, number> {
|
|
511
|
-
const idf = new Map<string, number>();
|
|
512
|
-
for (const term of terms) {
|
|
513
|
-
const df = index.df.get(term) ?? 0;
|
|
514
|
-
idf.set(term, Math.log(1 + (index.n - df + 0.5) / (df + 0.5)));
|
|
515
|
-
}
|
|
516
|
-
return idf;
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
/**
|
|
520
|
-
* The BM25 relevance of document `id` to `terms` — the sum over the query terms the
|
|
521
|
-
* document contains of `IDF(term) · saturated-tf`, using the precomputed {@link idfForTerms}.
|
|
522
|
-
* `avgdl` is safely positive wherever it is read: a term with a non-zero frequency means
|
|
523
|
-
* the document has tokens, which means the corpus does too.
|
|
524
|
-
*
|
|
525
|
-
* `id` is always an indexed document — the index is built from the same bundle
|
|
526
|
-
* {@link query} iterates, so every concept it scores has an entry (asserting that
|
|
527
|
-
* keeps the scorer straight-line, with no dead "missing doc" branch). Likewise every
|
|
528
|
-
* scored term has an `idf` entry (both come from the same query-term list).
|
|
529
|
-
*/
|
|
530
|
-
function scoreBm25(index: Bm25Index, idf: ReadonlyMap<string, number>, id: string, terms: readonly string[]): number {
|
|
531
|
-
const doc = index.docs.get(id) as IndexedDoc;
|
|
532
|
-
let score = 0;
|
|
533
|
-
for (const term of terms) {
|
|
534
|
-
const freq = doc.tf.get(term);
|
|
535
|
-
if (freq === undefined) {
|
|
536
|
-
continue;
|
|
537
|
-
}
|
|
538
|
-
const denominator = freq + BM25_K1 * (1 - BM25_B + (BM25_B * doc.length) / index.avgdl);
|
|
539
|
-
score += ((idf.get(term) as number) * (freq * (BM25_K1 + 1))) / denominator;
|
|
540
|
-
}
|
|
541
|
-
return score;
|
|
542
|
-
}
|