@nanobpm/urban 0.69.0 → 0.70.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.
@@ -0,0 +1,141 @@
1
+ // Slice S4 (retrieval) — the READ seam over the git system-of-record.
2
+ //
3
+ // Retrieval treats git as the authoritative store: records live on disk under
4
+ // the exact layout S3 defines (`records/<scope>/<scopeRef-bucket>/<id>.json`),
5
+ // and this module reads them back. It is deliberately abstracted behind a
6
+ // {@link RecordSource} interface so the derived hot cache, the query layer, and
7
+ // a future PII/mutable backend all read through one seam rather than assuming a
8
+ // public-git working copy. {@link GitRecordSource} is the default MVP: it walks
9
+ // a resolved substrate working copy (the local checkout S1 produces, pinned to
10
+ // its ref) using the S3 layout helpers to select record files.
11
+ //
12
+ // The source NEVER caches — it always reflects what is on disk (== the git ref
13
+ // that is checked out). Caching is the derived index's job (see cache.ts), and
14
+ // the index is disposable: a cold/empty index falls back to reading here, so
15
+ // retrieval is correct even before any index is built.
16
+ import { readdir, readFile } from "node:fs/promises";
17
+ import { isAbsolute, join, relative, sep } from "node:path";
18
+ import { isRecordPath, LAYOUT_ROOT, recordDir, scopeDir, } from "../git/index.js";
19
+ import { validateMemoryRecord, } from "../schema/index.js";
20
+ /** Thrown when the underlying substrate cannot be read at all. */
21
+ export class RecordSourceError extends Error {
22
+ constructor(message, options) {
23
+ super(message, options);
24
+ this.name = "RecordSourceError";
25
+ }
26
+ }
27
+ function isEnoent(error) {
28
+ return (typeof error === "object" &&
29
+ error !== null &&
30
+ "code" in error &&
31
+ error.code === "ENOENT");
32
+ }
33
+ /** Convert a native absolute path to a substrate-root-relative POSIX path. */
34
+ function toPosixRelative(root, absPath) {
35
+ return relative(root, absPath).split(sep).join("/");
36
+ }
37
+ /**
38
+ * The default git-backed read source: reads records straight off a local
39
+ * substrate working copy. Git is the system of record; this holds no state of
40
+ * its own, so every call reflects the current on-disk (== checked-out ref)
41
+ * content. That is what makes the derived index safe to throw away and rebuild.
42
+ */
43
+ export class GitRecordSource {
44
+ #root;
45
+ constructor(handle) {
46
+ const root = typeof handle === "string" ? handle : handle.localPath;
47
+ if (!isAbsolute(root)) {
48
+ throw new RecordSourceError(`substrate root must be an absolute path, got: ${root}`);
49
+ }
50
+ this.#root = root;
51
+ }
52
+ /** Absolute path of the substrate working copy this source reads from. */
53
+ get root() {
54
+ return this.#root;
55
+ }
56
+ async list(selector) {
57
+ const subtree = selectorSubtree(selector);
58
+ const walkRoot = join(this.#root, subtree);
59
+ let entries;
60
+ try {
61
+ entries = await readdir(walkRoot, { recursive: true, withFileTypes: true });
62
+ }
63
+ catch (error) {
64
+ // A missing subtree (e.g. an empty store, or a namespace with no records)
65
+ // is an empty result, not an error — a cold read must degrade gracefully.
66
+ if (isEnoent(error)) {
67
+ return [];
68
+ }
69
+ throw new RecordSourceError(`failed to list substrate at ${walkRoot}`, {
70
+ cause: error,
71
+ });
72
+ }
73
+ const results = [];
74
+ for (const entry of entries) {
75
+ if (!entry.isFile()) {
76
+ continue;
77
+ }
78
+ const absPath = join(entry.parentPath, entry.name);
79
+ const relPath = toPosixRelative(this.#root, absPath);
80
+ if (!isRecordPath(relPath)) {
81
+ continue;
82
+ }
83
+ const stored = await this.#readAbsolute(absPath, relPath);
84
+ if (stored !== undefined) {
85
+ results.push(stored);
86
+ }
87
+ }
88
+ // Deterministic order regardless of filesystem enumeration order.
89
+ results.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
90
+ return results;
91
+ }
92
+ async read(relPath) {
93
+ const normalised = relPath.replace(/\\/g, "/").replace(/^\.\//, "");
94
+ if (!isRecordPath(normalised)) {
95
+ return undefined;
96
+ }
97
+ return this.#readAbsolute(join(this.#root, normalised), normalised);
98
+ }
99
+ async #readAbsolute(absPath, relPath) {
100
+ let raw;
101
+ try {
102
+ raw = await readFile(absPath, "utf8");
103
+ }
104
+ catch (error) {
105
+ if (isEnoent(error)) {
106
+ return undefined;
107
+ }
108
+ throw new RecordSourceError(`failed to read record at ${relPath}`, {
109
+ cause: error,
110
+ });
111
+ }
112
+ let parsed;
113
+ try {
114
+ parsed = JSON.parse(raw);
115
+ }
116
+ catch {
117
+ // A corrupt / non-JSON file under the layout is skipped, not fatal.
118
+ return undefined;
119
+ }
120
+ const result = validateMemoryRecord(parsed);
121
+ if (!result.ok) {
122
+ // A file that does not validate against the S2 schema is not a record we
123
+ // can safely surface — skip it rather than return an unchecked shape.
124
+ return undefined;
125
+ }
126
+ return { path: relPath, record: result.record };
127
+ }
128
+ }
129
+ /**
130
+ * The layout subtree to walk for a selector: one namespace bucket, one whole
131
+ * scope, or the entire layout root. Keeps path-scoping in one place so the
132
+ * cache and query layers never re-derive layout paths themselves.
133
+ */
134
+ export function selectorSubtree(selector) {
135
+ if (selector === undefined) {
136
+ return LAYOUT_ROOT;
137
+ }
138
+ return selector.scopeRef === undefined
139
+ ? scopeDir(selector.scope)
140
+ : recordDir(selector.scope, selector.scopeRef);
141
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.69.0",
3
+ "version": "0.70.0",
4
4
  "description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -103,7 +103,7 @@
103
103
  "@nanobpm/nano-sdk": "^1.2.5",
104
104
  "@nanobpm/workflow": "^0.12.0",
105
105
  "bpmn-auto-layout": "^2.0.0-alpha.2",
106
- "create-urban-app": "^0.14.0",
106
+ "create-urban-app": "^0.15.0",
107
107
  "yaml": "^2.9.0"
108
108
  },
109
109
  "devDependencies": {
@@ -0,0 +1,68 @@
1
+ # `@nanobpm/urban/context/retrieval` — S4 retrieval over git
2
+
3
+ The **read** side of the Urban context layer. It queries the git
4
+ system-of-record that S3 (`@nanobpm/urban/context/git`) writes, using the exact
5
+ path/namespace layout S3 publishes — it does **not** invent its own layout.
6
+ MVP is **git-only**: no external service.
7
+
8
+ ## Query surface (`ContextRetriever`)
9
+
10
+ Bind a retriever to a resolved substrate handle (the local checkout S1 produces,
11
+ `{ localPath, ref }`):
12
+
13
+ ```ts
14
+ import { ContextRetriever } from "@nanobpm/urban/context/retrieval";
15
+ const retriever = new ContextRetriever({ localPath, ref: "main" });
16
+ ```
17
+
18
+ - `query(q)` — the general query. Clauses are **ANDed**; a clause given as an
19
+ array is an **OR-set**. Supports:
20
+ - **structured / path-scoped**: `scope`, `scopeRef` (uses S3's partitioning);
21
+ - **frontmatter filtering**: `mode`, `provenance`, `authority`, `id`,
22
+ `subject` (typed record fields);
23
+ - **text search**: `text` — case-insensitive substring across the record body
24
+ (`statement`, `subject`, `id`);
25
+ - `limit`.
26
+ - `byNamespace(scope, scopeRef?)` — path-scoped structured query for one layout
27
+ partition; reads just that subtree when the cache is cold.
28
+ - `search(text, filters?)` — text search combined with structured filters.
29
+ - `all()` — every record (warms the cache).
30
+ - `get(relPath)` — one record by its S3 layout path (bypasses the cache).
31
+
32
+ ## Git is the system of record; the cache is DERIVED and disposable
33
+
34
+ Retrieval is correct **with a cold/empty cache** — every query falls back to
35
+ reading git through the `RecordSource` seam. A query pinned to exactly one
36
+ namespace (`scope` + a single `scopeRef`) reads **only that layout partition**,
37
+ so a targeted query never pays to build the whole index.
38
+
39
+ The hot cache (`DerivedRecordIndex`) is a **snapshot projection**, rebuildable
40
+ purely from git and never authoritative:
41
+
42
+ - `retriever.invalidate()` — drop the projection; the next read re-reads git.
43
+ - `retriever.rebuild()` — eagerly re-project the whole store from git.
44
+ - `retriever.cache.isWarm` — whether a snapshot is currently held.
45
+
46
+ Callers keep the cache honest by calling `invalidate()` after a write (git
47
+ remains the source of truth). A warm cache serves its snapshot until
48
+ invalidated/rebuilt — it does not observe writes made after it was warmed.
49
+
50
+ ## Read seam (`RecordSource` / `GitRecordSource`)
51
+
52
+ Reads go through a `RecordSource` interface, not a hard-wired public-git
53
+ assumption. `GitRecordSource` is the default git-only MVP: it walks the resolved
54
+ working copy, selects record files with S3's `isRecordPath`, and validates each
55
+ against the S2 schema (`validateMemoryRecord`). Corrupt / non-record / invalid
56
+ files under the layout are **skipped, not fatal**. A future PII/mutable backend
57
+ can implement `RecordSource` without the cache, query layer, or callers changing.
58
+
59
+ ## Seam for a future opt-in embedding index (NOT built)
60
+
61
+ Semantic/vector retrieval (an OpenMemory / mem0-style **derived** embedding
62
+ index) is deliberately **not built** here — MVP retrieval is git-only lexical.
63
+ `embedding.ts` declares the `EmbeddingIndex` seam it would plug into
64
+ (`reindex(records)` + `semanticSearch(query, k)`), and `ContextRetriever`
65
+ accepts an optional `embeddingIndex` for forward-compat. Any such index MUST be
66
+ derived and disposable (rebuildable from git, never authoritative), exactly like
67
+ the hot cache — a cold embedding index must degrade to the git-backed lexical
68
+ path, never to stale/wrong results.
@@ -0,0 +1,95 @@
1
+ // Slice S4 (retrieval) — the DERIVED hot read cache/index.
2
+ //
3
+ // Git is the system of record; this index is a disposable, rebuildable-from-git
4
+ // projection kept hot for fast repeated reads. It is NEVER authoritative:
5
+ // - a cold (never-built) index transparently reads through to the source on
6
+ // first use, so retrieval is correct even before any rebuild;
7
+ // - `invalidate()` drops the projection so the next read re-reads from git;
8
+ // - `rebuild()` re-reads the whole store eagerly.
9
+ // Because it holds nothing git does not, throwing it away can never lose data.
10
+
11
+ import type { NamespaceSelector, RecordSource, StoredRecord } from "./source.ts";
12
+
13
+ /**
14
+ * A derived, in-memory index over a {@link RecordSource}. Holds a full snapshot
15
+ * of the store once warmed; cold reads and namespace-scoped reads fall back to
16
+ * the source directly so a cold/empty index is always correct.
17
+ */
18
+ export class DerivedRecordIndex {
19
+ readonly #source: RecordSource;
20
+ // `undefined` == cold (never loaded / invalidated). An empty array is a valid
21
+ // WARM state (a store that genuinely has no records), distinct from cold.
22
+ #snapshot: readonly StoredRecord[] | undefined;
23
+
24
+ constructor(source: RecordSource) {
25
+ this.#source = source;
26
+ }
27
+
28
+ /** `true` once a full snapshot is loaded; `false` when cold/invalidated. */
29
+ get isWarm(): boolean {
30
+ return this.#snapshot !== undefined;
31
+ }
32
+
33
+ /** The read source this index is derived from (git, by default). */
34
+ get source(): RecordSource {
35
+ return this.#source;
36
+ }
37
+
38
+ /**
39
+ * Eagerly (re)load the full store snapshot from the source, replacing any
40
+ * existing projection. Cheap to call and idempotent — the whole point is that
41
+ * rebuilding is always safe because git remains the source of truth.
42
+ */
43
+ async rebuild(): Promise<readonly StoredRecord[]> {
44
+ const snapshot = await this.#source.list();
45
+ this.#snapshot = snapshot;
46
+ return snapshot;
47
+ }
48
+
49
+ /** Drop the projection so the next read re-reads from git. */
50
+ invalidate(): void {
51
+ this.#snapshot = undefined;
52
+ }
53
+
54
+ /**
55
+ * Return the full store, warming the index on a cold read. After this the
56
+ * index is warm; subsequent calls serve the cached snapshot without touching
57
+ * the filesystem until {@link invalidate} or {@link rebuild} is called.
58
+ */
59
+ async all(): Promise<readonly StoredRecord[]> {
60
+ if (this.#snapshot !== undefined) {
61
+ return this.#snapshot;
62
+ }
63
+ return this.rebuild();
64
+ }
65
+
66
+ /**
67
+ * Return the records in one namespace partition. When the index is already
68
+ * warm this filters the in-memory snapshot (no I/O); when cold it does a
69
+ * cheap, path-scoped read of JUST that partition from the source WITHOUT
70
+ * warming the whole index — so a targeted query never pays to load the store.
71
+ */
72
+ async namespace(
73
+ selector: NamespaceSelector,
74
+ ): Promise<readonly StoredRecord[]> {
75
+ if (this.#snapshot !== undefined) {
76
+ return this.#snapshot.filter((stored) =>
77
+ inNamespace(stored, selector),
78
+ );
79
+ }
80
+ return this.#source.list(selector);
81
+ }
82
+ }
83
+
84
+ /** `true` iff a stored record belongs to the given namespace partition. */
85
+ function inNamespace(
86
+ stored: StoredRecord,
87
+ selector: NamespaceSelector,
88
+ ): boolean {
89
+ if (stored.record.scope !== selector.scope) {
90
+ return false;
91
+ }
92
+ return (
93
+ selector.scopeRef === undefined || stored.record.scopeRef === selector.scopeRef
94
+ );
95
+ }
@@ -0,0 +1,45 @@
1
+ // Slice S4 (retrieval) — SEAM ONLY for a future opt-in derived embedding index.
2
+ //
3
+ // The MVP retrieval is git-only: structured/path-scoped queries, frontmatter
4
+ // filtering, and substring text search over the git system-of-record, with a
5
+ // disposable hot cache. Semantic (vector) retrieval — an OpenMemory / mem0-style
6
+ // derived embedding index — is deliberately NOT built here. This file only
7
+ // declares the seam it would plug into, so a later slice can add it without
8
+ // reshaping the retriever or its public API.
9
+ //
10
+ // Like the hot cache, any such embedding index MUST be DERIVED and disposable:
11
+ // rebuildable purely from git, never authoritative. Nothing in this file
12
+ // performs embedding, network I/O, or persistence — it is a type contract.
13
+
14
+ import type { RetrievalQuery } from "./query.ts";
15
+ import type { StoredRecord } from "./source.ts";
16
+
17
+ /** A semantic match: a stored record plus the backend's similarity score. */
18
+ export interface SemanticMatch {
19
+ readonly stored: StoredRecord;
20
+ /** Backend-defined similarity score; higher is more similar. */
21
+ readonly score: number;
22
+ }
23
+
24
+ /**
25
+ * The seam a future opt-in embedding index would implement. A retriever could
26
+ * accept one and offer `semanticQuery`, but the MVP neither ships nor requires
27
+ * an implementation — retrieval is fully correct git-only without it.
28
+ *
29
+ * Implementations must treat the embedding store as a derived projection of git:
30
+ * {@link reindex} rebuilds it from the authoritative records, and a cold/missing
31
+ * embedding index must degrade to the git-backed lexical path, never to wrong
32
+ * or stale results.
33
+ */
34
+ export interface EmbeddingIndex {
35
+ /** (Re)build the derived vector index from the authoritative git records. */
36
+ reindex(records: readonly StoredRecord[]): Promise<void>;
37
+ /**
38
+ * Return the semantically nearest records to `query.text`, honouring the
39
+ * structured/frontmatter clauses of `query` as pre/post filters.
40
+ */
41
+ semanticSearch(
42
+ query: RetrievalQuery,
43
+ k: number,
44
+ ): Promise<readonly SemanticMatch[]>;
45
+ }
@@ -1,2 +1,47 @@
1
- // Slice S4 (retrieval) — owner replaces this placeholder barrel.
2
- export {};
1
+ // @nanobpm/urban/context/retrieval — slice S4 (retrieval over git).
2
+ //
3
+ // Read-side of the Urban context layer: query the git system-of-record that S3
4
+ // writes, using the exact path/namespace layout S3 publishes. MVP is git-only —
5
+ // NO external service:
6
+ //
7
+ // 1. STRUCTURED / PATH-SCOPED queries by scope/namespace, reading just one
8
+ // layout partition when a query pins it (cheap, cold-cache friendly).
9
+ // 2. FRONTMATTER filtering on typed record fields (scope/mode/provenance/
10
+ // authority/id/subject), ANDed clauses with OR-sets per field.
11
+ // 3. TEXT SEARCH across record bodies (case-insensitive substring).
12
+ //
13
+ // Git is the system of record; the hot read cache (DerivedRecordIndex) is a
14
+ // DISPOSABLE projection, rebuildable purely from git and never authoritative —
15
+ // retrieval is correct even with a cold/empty cache by falling back to the
16
+ // source. A future opt-in derived embedding index is left as a documented seam
17
+ // (see embedding.ts / EmbeddingIndex) and is NOT built here.
18
+
19
+ export {
20
+ ContextRetriever,
21
+ type ContextRetrieverOptions,
22
+ } from "./retriever.ts";
23
+
24
+ export { DerivedRecordIndex } from "./cache.ts";
25
+
26
+ export {
27
+ GitRecordSource,
28
+ type NamespaceSelector,
29
+ type RecordSource,
30
+ RecordSourceError,
31
+ type ResolvedReadHandle,
32
+ selectorSubtree,
33
+ type StoredRecord,
34
+ } from "./source.ts";
35
+
36
+ export {
37
+ compileQuery,
38
+ type CompiledQuery,
39
+ matchesQuery,
40
+ type OneOrMany,
41
+ type RetrievalQuery,
42
+ } from "./query.ts";
43
+
44
+ export {
45
+ type EmbeddingIndex,
46
+ type SemanticMatch,
47
+ } from "./embedding.ts";
@@ -0,0 +1,72 @@
1
+ // Slice S4 (retrieval) — unit tests for the pure query model.
2
+
3
+ import assert from "node:assert/strict";
4
+ import { test } from "node:test";
5
+ import type { MemoryRecord } from "../schema/index.ts";
6
+ import { compileQuery, matchesQuery, type RetrievalQuery } from "./query.ts";
7
+
8
+ function rec(overrides: Partial<MemoryRecord>): MemoryRecord {
9
+ const base: MemoryRecord = {
10
+ schemaVersion: 1,
11
+ id: "rec-1",
12
+ scope: "epic",
13
+ scopeRef: "issue-303",
14
+ mode: "empirical",
15
+ provenance: "measured",
16
+ authority: "authoritative",
17
+ statement: "the measured throughput was 42 rps",
18
+ createdAt: "2026-01-01T00:00:00Z",
19
+ };
20
+ return { ...base, ...overrides };
21
+ }
22
+
23
+ function matches(record: MemoryRecord, query: RetrievalQuery): boolean {
24
+ return matchesQuery(record, compileQuery(query));
25
+ }
26
+
27
+ test("an empty query matches every record", () => {
28
+ assert.equal(matches(rec({}), {}), true);
29
+ });
30
+
31
+ test("frontmatter clauses are ANDed", () => {
32
+ const record = rec({ provenance: "measured", mode: "empirical" });
33
+ assert.equal(matches(record, { provenance: "measured", mode: "empirical" }), true);
34
+ assert.equal(matches(record, { provenance: "measured", mode: "normative" }), false);
35
+ });
36
+
37
+ test("a clause given as an array is an OR-set", () => {
38
+ const record = rec({ scope: "repo" });
39
+ assert.equal(matches(record, { scope: ["epic", "repo"] }), true);
40
+ assert.equal(matches(record, { scope: ["epic", "corpus"] }), false);
41
+ assert.equal(matches(record, { scope: "repo" }), true);
42
+ });
43
+
44
+ test("scopeRef filter distinguishes namespaces", () => {
45
+ assert.equal(matches(rec({ scopeRef: "issue-303" }), { scopeRef: "issue-303" }), true);
46
+ assert.equal(matches(rec({ scopeRef: "issue-999" }), { scopeRef: "issue-303" }), false);
47
+ });
48
+
49
+ test("a scopeRef filter never matches a record without a scopeRef", () => {
50
+ const unscoped = rec({ scope: "corpus", scopeRef: undefined });
51
+ assert.equal(matches(unscoped, { scopeRef: "issue-303" }), false);
52
+ assert.equal(matches(unscoped, {}), true);
53
+ });
54
+
55
+ test("text search is a case-insensitive substring over the body", () => {
56
+ const record = rec({ statement: "The Measured Throughput was 42 RPS", subject: "latency" });
57
+ assert.equal(matches(record, { text: "throughput" }), true);
58
+ assert.equal(matches(record, { text: "THROUGHPUT" }), true);
59
+ assert.equal(matches(record, { text: "latency" }), true); // subject
60
+ assert.equal(matches(record, { text: "rec-1" }), true); // id
61
+ assert.equal(matches(record, { text: "absent" }), false);
62
+ });
63
+
64
+ test("an empty text needle matches everything", () => {
65
+ assert.equal(matches(rec({}), { text: "" }), true);
66
+ });
67
+
68
+ test("text search composes with structured filters (AND)", () => {
69
+ const record = rec({ provenance: "human", mode: "normative", statement: "prefer explicit errors" });
70
+ assert.equal(matches(record, { provenance: "human", text: "explicit" }), true);
71
+ assert.equal(matches(record, { provenance: "measured", text: "explicit" }), false);
72
+ });
@@ -0,0 +1,118 @@
1
+ // Slice S4 (retrieval) — the query model: frontmatter filtering + text search.
2
+ //
3
+ // A retrieval query is a conjunction of independent predicates: path-scoped
4
+ // namespace narrowing (scope/scopeRef), frontmatter filtering on the typed
5
+ // record fields (mode/provenance/authority/…), and a free-text search across
6
+ // the record body. Every clause is optional; an empty query matches everything.
7
+ // The matching here is pure and cache-agnostic — it runs identically over
8
+ // records read straight from git (cold cache) or served from the hot index.
9
+
10
+ import type {
11
+ MemoryAuthority,
12
+ MemoryMode,
13
+ MemoryProvenance,
14
+ MemoryRecord,
15
+ MemoryScope,
16
+ } from "../schema/index.ts";
17
+
18
+ /** A field value that may be given as a single value or an OR-set of values. */
19
+ export type OneOrMany<T> = T | readonly T[];
20
+
21
+ /**
22
+ * A retrieval query. All clauses are ANDed together; a clause given as an array
23
+ * is an OR over its members. Absent clauses impose no constraint.
24
+ */
25
+ export interface RetrievalQuery {
26
+ /** Structured / path-scoped: restrict to one or more scope ladder levels. */
27
+ readonly scope?: OneOrMany<MemoryScope>;
28
+ /** Structured / path-scoped: restrict to one or more `scopeRef` namespaces. */
29
+ readonly scopeRef?: OneOrMany<string>;
30
+ /** Frontmatter filter: normative vs empirical. */
31
+ readonly mode?: OneOrMany<MemoryMode>;
32
+ /** Frontmatter filter: where the record came from. */
33
+ readonly provenance?: OneOrMany<MemoryProvenance>;
34
+ /** Frontmatter filter: hypothesis vs authoritative. */
35
+ readonly authority?: OneOrMany<MemoryAuthority>;
36
+ /** Frontmatter filter: exact record id(s). */
37
+ readonly id?: OneOrMany<string>;
38
+ /** Frontmatter filter: exact subject(s). */
39
+ readonly subject?: OneOrMany<string>;
40
+ /**
41
+ * Free-text search across the record body — case-insensitive substring match
42
+ * over `statement`, `subject` (when present), and `id`. This is the "text
43
+ * search across record bodies" clause; it composes with the structured filters.
44
+ */
45
+ readonly text?: string;
46
+ /** Cap the number of results returned (applied after ordering). */
47
+ readonly limit?: number;
48
+ }
49
+
50
+ function toSet<T>(value: OneOrMany<T> | undefined): ReadonlySet<T> | undefined {
51
+ if (value === undefined) {
52
+ return undefined;
53
+ }
54
+ return new Set(Array.isArray(value) ? value : [value]);
55
+ }
56
+
57
+ function matchesOneOf<T>(
58
+ allowed: ReadonlySet<T> | undefined,
59
+ actual: T | undefined,
60
+ ): boolean {
61
+ if (allowed === undefined) {
62
+ return true;
63
+ }
64
+ return actual !== undefined && allowed.has(actual);
65
+ }
66
+
67
+ /**
68
+ * A query compiled to sets for O(1) membership tests. Building it once and
69
+ * reusing it across a candidate list avoids re-normalising the query per record.
70
+ */
71
+ export interface CompiledQuery {
72
+ readonly scope?: ReadonlySet<MemoryScope>;
73
+ readonly scopeRef?: ReadonlySet<string>;
74
+ readonly mode?: ReadonlySet<MemoryMode>;
75
+ readonly provenance?: ReadonlySet<MemoryProvenance>;
76
+ readonly authority?: ReadonlySet<MemoryAuthority>;
77
+ readonly id?: ReadonlySet<string>;
78
+ readonly subject?: ReadonlySet<string>;
79
+ readonly text?: string;
80
+ readonly limit?: number;
81
+ }
82
+
83
+ /** Compile a raw query into membership sets + a normalised (lowercased) needle. */
84
+ export function compileQuery(query: RetrievalQuery): CompiledQuery {
85
+ return {
86
+ scope: toSet(query.scope),
87
+ scopeRef: toSet(query.scopeRef),
88
+ mode: toSet(query.mode),
89
+ provenance: toSet(query.provenance),
90
+ authority: toSet(query.authority),
91
+ id: toSet(query.id),
92
+ subject: toSet(query.subject),
93
+ text: query.text === undefined ? undefined : query.text.toLowerCase(),
94
+ limit: query.limit,
95
+ };
96
+ }
97
+
98
+ /** `true` iff `record` satisfies every clause of the compiled query. */
99
+ export function matchesQuery(record: MemoryRecord, query: CompiledQuery): boolean {
100
+ if (!matchesOneOf(query.scope, record.scope)) return false;
101
+ if (!matchesOneOf(query.scopeRef, record.scopeRef)) return false;
102
+ if (!matchesOneOf(query.mode, record.mode)) return false;
103
+ if (!matchesOneOf(query.provenance, record.provenance)) return false;
104
+ if (!matchesOneOf(query.authority, record.authority)) return false;
105
+ if (!matchesOneOf(query.id, record.id)) return false;
106
+ if (!matchesOneOf(query.subject, record.subject)) return false;
107
+ if (query.text !== undefined && !matchesText(record, query.text)) return false;
108
+ return true;
109
+ }
110
+
111
+ /** Case-insensitive substring search across the record's textual body. */
112
+ function matchesText(record: MemoryRecord, needle: string): boolean {
113
+ if (needle === "") {
114
+ return true;
115
+ }
116
+ const haystacks = [record.statement, record.subject ?? "", record.id];
117
+ return haystacks.some((field) => field.toLowerCase().includes(needle));
118
+ }