@nanobpm/urban 0.69.1 → 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.
- package/dist/context/retrieval/cache.d.ts +35 -0
- package/dist/context/retrieval/cache.js +75 -0
- package/dist/context/retrieval/embedding.d.ts +27 -0
- package/dist/context/retrieval/embedding.js +13 -0
- package/dist/context/retrieval/index.d.ts +5 -1
- package/dist/context/retrieval/index.js +21 -1
- package/dist/context/retrieval/query.d.ts +50 -0
- package/dist/context/retrieval/query.js +62 -0
- package/dist/context/retrieval/retriever.d.ts +63 -0
- package/dist/context/retrieval/retriever.js +136 -0
- package/dist/context/retrieval/source.d.ts +71 -0
- package/dist/context/retrieval/source.js +141 -0
- package/package.json +1 -1
- package/src/context/retrieval/NOTES.md +68 -0
- package/src/context/retrieval/cache.ts +95 -0
- package/src/context/retrieval/embedding.ts +45 -0
- package/src/context/retrieval/index.ts +47 -2
- package/src/context/retrieval/query.test.ts +72 -0
- package/src/context/retrieval/query.ts +118 -0
- package/src/context/retrieval/retriever.test.ts +187 -0
- package/src/context/retrieval/retriever.ts +193 -0
- package/src/context/retrieval/source.test.ts +118 -0
- package/src/context/retrieval/source.ts +218 -0
|
@@ -0,0 +1,218 @@
|
|
|
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
|
+
|
|
17
|
+
import type { Dirent } from "node:fs";
|
|
18
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
19
|
+
import { isAbsolute, join, relative, sep } from "node:path";
|
|
20
|
+
import {
|
|
21
|
+
isRecordPath,
|
|
22
|
+
LAYOUT_ROOT,
|
|
23
|
+
recordDir,
|
|
24
|
+
scopeDir,
|
|
25
|
+
} from "../git/index.ts";
|
|
26
|
+
import {
|
|
27
|
+
type MemoryRecord,
|
|
28
|
+
type MemoryScope,
|
|
29
|
+
validateMemoryRecord,
|
|
30
|
+
} from "../schema/index.ts";
|
|
31
|
+
|
|
32
|
+
/** A record as it is stored in the substrate: its layout path + the record. */
|
|
33
|
+
export interface StoredRecord {
|
|
34
|
+
/** Substrate-root-relative POSIX path the record lives at (S3 layout). */
|
|
35
|
+
readonly path: string;
|
|
36
|
+
/** The validated memory record parsed from that file. */
|
|
37
|
+
readonly record: MemoryRecord;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A path-scoped selector for a namespace partition in the S3 layout. Reading a
|
|
42
|
+
* single `(scope, scopeRef)` — or a whole `scope` — is a cheap structured query
|
|
43
|
+
* because the layout partitions records into one directory per namespace, so the
|
|
44
|
+
* source can walk just that subtree instead of the whole store.
|
|
45
|
+
*/
|
|
46
|
+
export interface NamespaceSelector {
|
|
47
|
+
readonly scope: MemoryScope;
|
|
48
|
+
/** When present, narrows to one namespace bucket; absent lists the scope. */
|
|
49
|
+
readonly scopeRef?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The read seam retrieval is built on. A future mutable/PII backend can
|
|
54
|
+
* implement this differently (e.g. against a database) without the cache, the
|
|
55
|
+
* query layer, or callers changing.
|
|
56
|
+
*/
|
|
57
|
+
export interface RecordSource {
|
|
58
|
+
/**
|
|
59
|
+
* List every stored record, optionally narrowed to one namespace partition
|
|
60
|
+
* (a cheap, path-scoped read that walks only that subtree of the S3 layout).
|
|
61
|
+
* Invalid / unparseable files under the layout root are skipped, not fatal —
|
|
62
|
+
* a single corrupt file must never make the whole store unreadable.
|
|
63
|
+
*/
|
|
64
|
+
list(selector?: NamespaceSelector): Promise<readonly StoredRecord[]>;
|
|
65
|
+
/**
|
|
66
|
+
* Read a single record by its substrate-relative layout path, or `undefined`
|
|
67
|
+
* when it is absent or not a valid record file.
|
|
68
|
+
*/
|
|
69
|
+
read(relPath: string): Promise<StoredRecord | undefined>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The minimal resolved-handle shape the read source consumes (from S1). */
|
|
73
|
+
export interface ResolvedReadHandle {
|
|
74
|
+
/** Absolute path to the local substrate working copy (checked out at `ref`). */
|
|
75
|
+
readonly localPath: string;
|
|
76
|
+
/** The git ref the working copy is pinned to (informational for reads). */
|
|
77
|
+
readonly ref?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Thrown when the underlying substrate cannot be read at all. */
|
|
81
|
+
export class RecordSourceError extends Error {
|
|
82
|
+
constructor(message: string, options?: { cause?: unknown }) {
|
|
83
|
+
super(message, options);
|
|
84
|
+
this.name = "RecordSourceError";
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isEnoent(error: unknown): boolean {
|
|
89
|
+
return (
|
|
90
|
+
typeof error === "object" &&
|
|
91
|
+
error !== null &&
|
|
92
|
+
"code" in error &&
|
|
93
|
+
error.code === "ENOENT"
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Convert a native absolute path to a substrate-root-relative POSIX path. */
|
|
98
|
+
function toPosixRelative(root: string, absPath: string): string {
|
|
99
|
+
return relative(root, absPath).split(sep).join("/");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The default git-backed read source: reads records straight off a local
|
|
104
|
+
* substrate working copy. Git is the system of record; this holds no state of
|
|
105
|
+
* its own, so every call reflects the current on-disk (== checked-out ref)
|
|
106
|
+
* content. That is what makes the derived index safe to throw away and rebuild.
|
|
107
|
+
*/
|
|
108
|
+
export class GitRecordSource implements RecordSource {
|
|
109
|
+
readonly #root: string;
|
|
110
|
+
|
|
111
|
+
constructor(handle: ResolvedReadHandle | string) {
|
|
112
|
+
const root = typeof handle === "string" ? handle : handle.localPath;
|
|
113
|
+
if (!isAbsolute(root)) {
|
|
114
|
+
throw new RecordSourceError(
|
|
115
|
+
`substrate root must be an absolute path, got: ${root}`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
this.#root = root;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Absolute path of the substrate working copy this source reads from. */
|
|
122
|
+
get root(): string {
|
|
123
|
+
return this.#root;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async list(selector?: NamespaceSelector): Promise<readonly StoredRecord[]> {
|
|
127
|
+
const subtree = selectorSubtree(selector);
|
|
128
|
+
const walkRoot = join(this.#root, subtree);
|
|
129
|
+
|
|
130
|
+
let entries: Dirent[];
|
|
131
|
+
try {
|
|
132
|
+
entries = await readdir(walkRoot, { recursive: true, withFileTypes: true });
|
|
133
|
+
} catch (error) {
|
|
134
|
+
// A missing subtree (e.g. an empty store, or a namespace with no records)
|
|
135
|
+
// is an empty result, not an error — a cold read must degrade gracefully.
|
|
136
|
+
if (isEnoent(error)) {
|
|
137
|
+
return [];
|
|
138
|
+
}
|
|
139
|
+
throw new RecordSourceError(`failed to list substrate at ${walkRoot}`, {
|
|
140
|
+
cause: error,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const results: StoredRecord[] = [];
|
|
145
|
+
for (const entry of entries) {
|
|
146
|
+
if (!entry.isFile()) {
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const absPath = join(entry.parentPath, entry.name);
|
|
150
|
+
const relPath = toPosixRelative(this.#root, absPath);
|
|
151
|
+
if (!isRecordPath(relPath)) {
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const stored = await this.#readAbsolute(absPath, relPath);
|
|
155
|
+
if (stored !== undefined) {
|
|
156
|
+
results.push(stored);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// Deterministic order regardless of filesystem enumeration order.
|
|
160
|
+
results.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
161
|
+
return results;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async read(relPath: string): Promise<StoredRecord | undefined> {
|
|
165
|
+
const normalised = relPath.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
166
|
+
if (!isRecordPath(normalised)) {
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
return this.#readAbsolute(join(this.#root, normalised), normalised);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async #readAbsolute(
|
|
173
|
+
absPath: string,
|
|
174
|
+
relPath: string,
|
|
175
|
+
): Promise<StoredRecord | undefined> {
|
|
176
|
+
let raw: string;
|
|
177
|
+
try {
|
|
178
|
+
raw = await readFile(absPath, "utf8");
|
|
179
|
+
} catch (error) {
|
|
180
|
+
if (isEnoent(error)) {
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
throw new RecordSourceError(`failed to read record at ${relPath}`, {
|
|
184
|
+
cause: error,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
let parsed: unknown;
|
|
189
|
+
try {
|
|
190
|
+
parsed = JSON.parse(raw);
|
|
191
|
+
} catch {
|
|
192
|
+
// A corrupt / non-JSON file under the layout is skipped, not fatal.
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const result = validateMemoryRecord(parsed);
|
|
197
|
+
if (!result.ok) {
|
|
198
|
+
// A file that does not validate against the S2 schema is not a record we
|
|
199
|
+
// can safely surface — skip it rather than return an unchecked shape.
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
return { path: relPath, record: result.record };
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The layout subtree to walk for a selector: one namespace bucket, one whole
|
|
208
|
+
* scope, or the entire layout root. Keeps path-scoping in one place so the
|
|
209
|
+
* cache and query layers never re-derive layout paths themselves.
|
|
210
|
+
*/
|
|
211
|
+
export function selectorSubtree(selector?: NamespaceSelector): string {
|
|
212
|
+
if (selector === undefined) {
|
|
213
|
+
return LAYOUT_ROOT;
|
|
214
|
+
}
|
|
215
|
+
return selector.scopeRef === undefined
|
|
216
|
+
? scopeDir(selector.scope)
|
|
217
|
+
: recordDir(selector.scope, selector.scopeRef);
|
|
218
|
+
}
|