@leu2m/semantic-search 0.2.0-beta.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/LICENSE +21 -0
- package/README.md +146 -0
- package/dist/adapters/file-storage.d.ts +8 -0
- package/dist/adapters/file-storage.js +55 -0
- package/dist/adapters/file-storage.js.map +1 -0
- package/dist/adapters/filesystem.d.ts +23 -0
- package/dist/adapters/filesystem.js +119 -0
- package/dist/adapters/filesystem.js.map +1 -0
- package/dist/adapters/minilm.d.ts +50 -0
- package/dist/adapters/minilm.js +141 -0
- package/dist/adapters/minilm.js.map +1 -0
- package/dist/catalog.d.ts +3 -0
- package/dist/catalog.js +26 -0
- package/dist/catalog.js.map +1 -0
- package/dist/chunking.d.ts +9 -0
- package/dist/chunking.js +54 -0
- package/dist/chunking.js.map +1 -0
- package/dist/contracts.d.ts +163 -0
- package/dist/contracts.js +4 -0
- package/dist/contracts.js.map +1 -0
- package/dist/embedding-input.d.ts +4 -0
- package/dist/embedding-input.js +13 -0
- package/dist/embedding-input.js.map +1 -0
- package/dist/engine.d.ts +63 -0
- package/dist/engine.js +257 -0
- package/dist/engine.js.map +1 -0
- package/dist/file-types/registry.d.ts +13 -0
- package/dist/file-types/registry.js +47 -0
- package/dist/file-types/registry.js.map +1 -0
- package/dist/file-types/types.d.ts +11 -0
- package/dist/file-types/types.js +2 -0
- package/dist/file-types/types.js.map +1 -0
- package/dist/index-state.d.ts +17 -0
- package/dist/index-state.js +91 -0
- package/dist/index-state.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/parsers/index.d.ts +28 -0
- package/dist/parsers/index.js +113 -0
- package/dist/parsers/index.js.map +1 -0
- package/dist/retrieval.d.ts +20 -0
- package/dist/retrieval.js +108 -0
- package/dist/retrieval.js.map +1 -0
- package/docs/alpha5-hardening.md +86 -0
- package/docs/alpha6-answerability.md +131 -0
- package/docs/alpha6-c-validation.md +490 -0
- package/docs/alpha6-evidence-traces.md +486 -0
- package/docs/api.md +43 -0
- package/docs/architecture.md +121 -0
- package/docs/benchmarks/alpha5-retrieval.json +5233 -0
- package/docs/benchmarks/alpha5-scale.json +505 -0
- package/docs/benchmarks/alpha6-evidence.json +14185 -0
- package/docs/benchmarks/alpha6c-heldout-real.json +9389 -0
- package/docs/decisions/0001-minilm-loading.md +32 -0
- package/docs/decisions/0002-retrieval-modes.md +26 -0
- package/docs/decisions/0003-retrieval-evidence-boundary.md +19 -0
- package/docs/evaluation.md +315 -0
- package/docs/file-types.md +31 -0
- package/docs/integration.md +204 -0
- package/docs/next-slice.md +11 -0
- package/examples/README.md +38 -0
- package/examples/core.mjs +53 -0
- package/examples/evidence.mjs +20 -0
- package/examples/filesystem.mjs +9 -0
- package/examples/minilm.mjs +15 -0
- package/package.json +73 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 leu2m
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# @leu2m/semantic-search
|
|
2
|
+
|
|
3
|
+
Source-oriented retrieval infrastructure for independent host products. Sources supply documents; ordinary code recognizes file types, parses structure, chunks content, narrows a catalog and returns ranked evidence with provenance. Vector and Nexus can consume the same API without either product owning the engine.
|
|
4
|
+
|
|
5
|
+
**Status: `0.1.0-alpha.5`, ready for experimental use.** Lexical, semantic and hybrid retrieval are implemented. Sources are parsed into chunks and a deterministic catalog. Optional storage persists records and vectors; MiniLM or a custom embedder generates vectors with exact-input reuse across refreshes, renames and restart. Search now supports exact cosine and lexical/semantic Reciprocal Rank Fusion (RRF), with full provenance. Core remains host-neutral and imports no model runtime.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
The repository provides an ESM package with TypeScript declarations. Requires Node 20+ for the filesystem adapter. It is not yet published to npm.
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npm install git+ssh://git@github.com/leu2m/semantic-search.git#main
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Pin a commit instead of `main` for reproducible consumption. Git installation builds the package through `prepare`. For a local checkout, run `npm ci`, `npm run build`, then `npm pack`; install the resulting tarball in the consumer. Registry publication is a later release step. Built-package core consumers do not install Transformers.js automatically. Git/source builds install development dependencies, including the runtime for typechecking; use a built tarball when those build dependencies are unwanted.
|
|
16
|
+
|
|
17
|
+
## Use
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { SemanticSearchEngine, createSearchSession } from '@leu2m/semantic-search';
|
|
21
|
+
import { FileSystemFolderSource } from '@leu2m/semantic-search/filesystem';
|
|
22
|
+
|
|
23
|
+
// The host obtains approval/configuration for this root before registering it.
|
|
24
|
+
const source = new FileSystemFolderSource({
|
|
25
|
+
id: 'project',
|
|
26
|
+
root: configuredAbsoluteRoot,
|
|
27
|
+
});
|
|
28
|
+
const engine = new SemanticSearchEngine();
|
|
29
|
+
engine.addSource(source);
|
|
30
|
+
await engine.initialize();
|
|
31
|
+
|
|
32
|
+
const run = createSearchSession(engine);
|
|
33
|
+
const results = await run.search({
|
|
34
|
+
query: 'validateSession',
|
|
35
|
+
sources: ['project'],
|
|
36
|
+
filters: { extensions: ['.ts'] },
|
|
37
|
+
limit: 8,
|
|
38
|
+
});
|
|
39
|
+
// Each hit includes sourceId, documentId, chunkId, URI, lines and snippet.
|
|
40
|
+
// After host-observed changes:
|
|
41
|
+
await engine.refreshSource('project');
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`search({ query, mode?, sources?, filters?, limit? })` is one retrieval API:
|
|
45
|
+
|
|
46
|
+
| Mode | Behavior |
|
|
47
|
+
| --- | --- |
|
|
48
|
+
| `lexical` | Existing filename/code-symbol/content scoring; never calls `embedQuery`. |
|
|
49
|
+
| `semantic` | Requires an embedder; exact cosine over eligible stored vectors. |
|
|
50
|
+
| `hybrid` | Requires an embedder; complete lexical/semantic rankings fused with RRF, k=60. |
|
|
51
|
+
|
|
52
|
+
Omitted mode defaults to **hybrid with an embedder**, otherwise lexical. This deliberately changes Alpha.3's embedder-enabled default; request `mode: 'lexical'` to retain it. `engine.defaultMode` exposes the effective default. Explicit semantic/hybrid requests without an embedder fail.
|
|
53
|
+
|
|
54
|
+
Filters narrow sources, folders, extensions, dates, tags and outgoing links **before ranking**. The final limit applies after fusion. Channel ranks and `combinedRank` are one-based positions, not confidence scores; match reasons identify lexical/semantic evidence. A bounded run cache includes effective mode and avoids repeated query inference. See the [retrieval decision](docs/decisions/0002-retrieval-modes.md) for consistency and cache semantics.
|
|
55
|
+
|
|
56
|
+
## Sources and extension points
|
|
57
|
+
|
|
58
|
+
- `SearchSource`: host-supplied `list` and `read`; document IDs are scoped by source ID. An Obsidian vault adapter belongs in the host and can enrich metadata without importing Obsidian into this package.
|
|
59
|
+
- `FileSystemFolderSource`: recursive, explicitly registered root; no symlinks followed in this release. Containment and file identity are checked on reads. It refuses unknown types, oversized files and invalid UTF-8/binary contents. It does not install watchers: hosts call `refreshSource` after changes or use their own debounced watcher.
|
|
60
|
+
- `ParserRegistry`: register custom parsers and, for additional formats, a shared `FileTypeRegistry`. Missing parsers can fall back to text only when the definition permits it. Default parsers cover Markdown, text, generic code and structured configuration as text.
|
|
61
|
+
- `Embedder` and `IndexStorage`: independent optional engine options. Use neither for lexical memory indexing, storage alone for persisted lexical records, an embedder alone for in-process vectors, or both for restart reuse. Hosts own storage locations and model/worker execution.
|
|
62
|
+
- `MiniLMEmbedder`: optional portable `/minilm` export using an exact optional Transformers.js peer; never imported by core.
|
|
63
|
+
- `FileIndexStorage`: atomic JSON persistence available only from the Node-only `filesystem` export; supply an absolute file path whose parent directory already exists.
|
|
64
|
+
|
|
65
|
+
The [canonical generated file table](docs/file-types.md) covers Markdown/text, JS/TS, common programming languages, web/style files, JSON/YAML/TOML, shell and SQL. No binary extraction is implemented.
|
|
66
|
+
|
|
67
|
+
## Optional persistence and vectors
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
// These are host implementations of the exported contracts, not package factories.
|
|
71
|
+
const engine = new SemanticSearchEngine({ storage: myIndexStorage, embedder: myEmbedder });
|
|
72
|
+
engine.addSource(mySource);
|
|
73
|
+
await engine.initialize({ signal });
|
|
74
|
+
const report = await engine.refreshSource(mySource.id, { signal });
|
|
75
|
+
// report.embeddedTexts counts new inference inputs; unchanged content needs none.
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The same core works with browser sources, IndexedDB-backed host storage and a worker embedder, or with Node/CLI/desktop adapters. The root export never imports those host implementations. Custom parser registries need an explicit behavior version to reuse persisted snapshots. See [integration](docs/integration.md) for configuration and [architecture](docs/architecture.md) for compatibility and transaction details.
|
|
79
|
+
|
|
80
|
+
## Local embeddings
|
|
81
|
+
|
|
82
|
+
Install the runtime only when using `/minilm`:
|
|
83
|
+
|
|
84
|
+
```sh
|
|
85
|
+
npm install @huggingface/transformers@4.2.0
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
import { MiniLMEmbedder } from '@leu2m/semantic-search/minilm';
|
|
90
|
+
const embedder = new MiniLMEmbedder({ device: 'cpu', batchSize: 4 });
|
|
91
|
+
const engine = new SemanticSearchEngine({ embedder, storage: myIndexStorage });
|
|
92
|
+
engine.addSource(mySource);
|
|
93
|
+
try {
|
|
94
|
+
await engine.initialize();
|
|
95
|
+
const evidence = await engine.search({ query: 'recover a forgotten password' }); // hybrid
|
|
96
|
+
}
|
|
97
|
+
finally { await embedder.dispose(); } // Host owns lifetime; dispose after all users finish.
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Import and construction do not load the runtime or model. First inference lazily loads `Xenova/all-MiniLM-L6-v2` at revision `751bff37182d3f1213fa05d7196b954e230abad9`. Defaults are CPU, fp32, mean pooling and normalized 384-dimensional Float32 vectors. Browser/worker hosts choose `device: 'wasm'` or supported WebGPU, and own execution placement and runtime assets. The adapter implements document and query embeddings; semantic/hybrid search embeds each uncached nonempty query once when eligible chunks exist.
|
|
101
|
+
|
|
102
|
+
Embedding-input version 1 includes content-derived heading context for continuation chunks without repeating an initial Markdown heading. Paths/URIs do not participate. Schema 3 invalidates older snapshots before reuse; the generic engine sends unique missing inputs once and the adapter owns batching.
|
|
103
|
+
|
|
104
|
+
| Loading mode | Contract |
|
|
105
|
+
| --- | --- |
|
|
106
|
+
| Online/cached Hub model | Supported; may access the network. |
|
|
107
|
+
| Host-staged local assets | Strict offline supported with host runtime configuration and remote loading disabled. |
|
|
108
|
+
| Hub cache alone + `localFilesOnly` | Not a zero-network guarantee in Transformers.js 4.2.0. |
|
|
109
|
+
|
|
110
|
+
`cacheDir` is a cache location. `localFilesOnly` forwards a runtime option whose 4.2.0 tokenizer metadata probes can still access the network for Hub IDs. Strict offline deployment uses staged local assets and **host-owned** environment configuration; the adapter never changes global runtime settings. See [integration](docs/integration.md#strict-offline-host-staged-assets) and [ADR 0001](docs/decisions/0001-minilm-loading.md) for the verified limitation, staging layout and ownership rules.
|
|
111
|
+
|
|
112
|
+
Alpha.5 hardens cache semantics and characterizes quality, scale and portability without changing ranking. See the [release record](docs/alpha5-hardening.md), [public API responsibilities](docs/api.md) and [maintained examples](examples/README.md).
|
|
113
|
+
|
|
114
|
+
Alpha.6A+B establishes an explicit **retrieval-only answerability boundary**; see the [design record](docs/alpha6-answerability.md) and [chunk evidence traces](docs/alpha6-evidence-traces.md). Results remain unassessed candidates; an empty list does not prove corpus-wide absence. The tested [host example](examples/evidence.mjs) labels both empty and nonempty retrieval as `answerability: 'not-assessed'`, propagates failures and preserves provenance. This is consumer example code, not a new package API. Runtime/ranking is unchanged, so the package stays `0.1.0-alpha.5`; independent Alpha.6C review is next.
|
|
115
|
+
|
|
116
|
+
## Development
|
|
117
|
+
|
|
118
|
+
```sh
|
|
119
|
+
npm ci
|
|
120
|
+
npm run typecheck
|
|
121
|
+
npm test
|
|
122
|
+
npm run build
|
|
123
|
+
npm run docs:file-types
|
|
124
|
+
npm run benchmark
|
|
125
|
+
npm pack
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
`npm test` uses deterministic fixtures and does not download/load a model. `npm run benchmark` retains the original lexical/lifecycle baselines and compares all three retrieval modes with deterministic test vectors. The explicit `npm run test:minilm` command may download model assets and separately measures real inference, lifecycle reuse, staged strict offline operation, and the known cache-only limitation. Use the separate `npm run benchmark:minilm` for actual three-mode retrieval quality, or append `-- --staged` after provisioning assets for strict local evaluation. See [evaluation](docs/evaluation.md) for results and individual modes.
|
|
129
|
+
|
|
130
|
+
The richer Alpha.5 fixture has 64 documents, 72 chunks and 50 queries. On 40 answerable queries, document-level Recall@5/@10 is **0.6417/0.7813 lexical**, **0.8688/0.9479 semantic**, **0.8479/0.9292 hybrid**. Semantic leads on conceptual/noisy queries; hybrid recovers some terminology and multi-document evidence. Every mode returns irrelevant candidates on the ten no-answer/near-miss queries. Ranks are not answer confidence, and no rejection threshold is implemented.
|
|
131
|
+
|
|
132
|
+
`npm run evaluate` validates representative evaluation offline; `npm run evaluate:minilm -- --staged` measures real quality from provisioned local assets. `npm run benchmark:scale` measures 1k–100k synthetic chunks separately from inference. Real Chrome page/worker core consumption and opt-in MiniLM WASM retrieval passed using installed tarballs and no external requests. Use `npm run test:browser` and `npm run test:browser:minilm` to reproduce after provisioning browser/model assets. See [evaluation](docs/evaluation.md) for all precision/recall/MRR/nDCG class breakdowns, raw chunk coverage and limitations.
|
|
133
|
+
|
|
134
|
+
On the measured older laptop, 100k unfiltered 384-dimensional candidates took about **0.79 seconds semantic / 1.44 seconds hybrid**. Scope searches and choose host worker placement for interactive workloads. These are observations, not package guarantees. Alpha.5 remains experimental rather than Beta; [next slice](docs/next-slice.md) recommends evidence-driven relevance/answerability design before broader integration.
|
|
135
|
+
|
|
136
|
+
`npm run evaluate:evidence` exercises the separate Alpha.6 development judgments offline; `npm run evaluate:evidence:minilm -- --staged` measures real chunk/set retrieval on the unchanged corpus. Judgments are authored development evidence, not independent validation. No answerability classifier, public confidence/cosine field, or rejection threshold has been added.
|
|
137
|
+
|
|
138
|
+
Every change and accepted decision must update the relevant docs and README in the same work. ADRs record durable choices; ignored local planning is supplementary.
|
|
139
|
+
|
|
140
|
+
## Boundaries and limitations
|
|
141
|
+
|
|
142
|
+
The core export contains no Node filesystem, Obsidian, Vector or Nexus imports and has zero runtime dependencies. The separate filesystem export is Node-only. Vector's esbuild bundle can consume the ESM core; Nexus's ESM core/daemon can import it directly. There is no unbundled CommonJS `require` export.
|
|
143
|
+
|
|
144
|
+
The live index remains in memory. Exact semantic ranking costs O(N·D + N log N) for N eligible chunks and D dimensions; hybrid adds O(N log N) fusion and O(N) temporary references, without copying stored vectors. There is no ANN, calibrated confidence, relevance cutoff or large-corpus performance claim. Refresh rescans and parses a source, reusing identical chunk records and exact-content vectors. Compatible persisted records can be reused after restart only after reconciliation with live sources. Failed scans, embeddings or saves preserve the source's last good live index and surface errors; hosts decide when stale results should be disabled. Storage must publish atomically; each successful save precedes the live commit. Markdown recognition is intentionally lightweight, frontmatter is preserved raw, and code parsing is not AST-based. Chunk sizes are soft character targets: a single long line is never cut. Source roots should be curated—there is no automatic ignore-file interpreter or directory exclusion policy.
|
|
145
|
+
|
|
146
|
+
See [API](docs/api.md), [examples](examples/README.md), [architecture](docs/architecture.md), [integration](docs/integration.md), [evaluation](docs/evaluation.md), [MiniLM loading decision](docs/decisions/0001-minilm-loading.md), and [next slice](docs/next-slice.md). MIT © 2026 leu2m.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { IndexSnapshot, IndexStorage, OperationOptions } from '../contracts.js';
|
|
2
|
+
/** Atomic single-writer store at an explicit host-chosen absolute path. Parent must exist. */
|
|
3
|
+
export declare class FileIndexStorage implements IndexStorage {
|
|
4
|
+
private readonly path;
|
|
5
|
+
constructor(path: string);
|
|
6
|
+
load(options?: OperationOptions): Promise<IndexSnapshot | null>;
|
|
7
|
+
save(snapshot: IndexSnapshot, options?: OperationOptions): Promise<void>;
|
|
8
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { open, readFile, rename, unlink } from 'node:fs/promises';
|
|
2
|
+
import { isAbsolute } from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
/** Atomic single-writer store at an explicit host-chosen absolute path. Parent must exist. */
|
|
5
|
+
export class FileIndexStorage {
|
|
6
|
+
path;
|
|
7
|
+
constructor(path) {
|
|
8
|
+
this.path = path;
|
|
9
|
+
if (!isAbsolute(path))
|
|
10
|
+
throw new Error('An explicit absolute index path is required');
|
|
11
|
+
}
|
|
12
|
+
async load(options = {}) {
|
|
13
|
+
options.signal?.throwIfAborted();
|
|
14
|
+
try {
|
|
15
|
+
const text = await readFile(this.path, { encoding: 'utf8', ...options });
|
|
16
|
+
options.signal?.throwIfAborted();
|
|
17
|
+
return JSON.parse(text);
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
options.signal?.throwIfAborted();
|
|
21
|
+
if (error.code === 'ENOENT')
|
|
22
|
+
return null;
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
async save(snapshot, options = {}) {
|
|
27
|
+
options.signal?.throwIfAborted();
|
|
28
|
+
const text = JSON.stringify(snapshot);
|
|
29
|
+
const temporary = `${this.path}.${randomUUID()}.tmp`;
|
|
30
|
+
const handle = await open(temporary, 'wx', 0o600);
|
|
31
|
+
let closed = false;
|
|
32
|
+
let published = false;
|
|
33
|
+
try {
|
|
34
|
+
await handle.writeFile(text, { encoding: 'utf8', ...options });
|
|
35
|
+
await handle.sync();
|
|
36
|
+
await handle.close();
|
|
37
|
+
closed = true;
|
|
38
|
+
options.signal?.throwIfAborted();
|
|
39
|
+
// rename is the publication boundary; cancellation after this point is too late.
|
|
40
|
+
await rename(temporary, this.path);
|
|
41
|
+
published = true;
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
try {
|
|
45
|
+
if (!closed)
|
|
46
|
+
await handle.close();
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
if (!published)
|
|
50
|
+
await unlink(temporary);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
//# sourceMappingURL=file-storage.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"file-storage.js","sourceRoot":"","sources":["../../src/adapters/file-storage.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAClE,OAAO,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AACvC,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAEzC,8FAA8F;AAC9F,MAAM,OAAO,gBAAgB;IACE;IAA7B,YAA6B,IAAY;QAAZ,SAAI,GAAJ,IAAI,CAAQ;QACvC,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC;YAAE,MAAM,IAAI,KAAK,CAAC,6CAA6C,CAAC,CAAC;IACxF,CAAC;IACD,KAAK,CAAC,IAAI,CAAC,UAA4B,EAAE;QACvC,OAAO,CAAC,MAAM,EAAE,cAAc,EAAE,CAAC;QACjC,IAAI,CAAC;YACH,MAAM,IAAI,GAAG,MAAM,QAAQ,CAAC,IAAI,CAAC,IAAI,EAAE,EAAE,QAAQ,EAAE,MAAM,EAAE,GAAG,OAAO,EAAE,CAAC,CAAC;YACzE,OAAO,CAAC,MAAM,EAAE,cAAc,EAAE,CAAC;YACjC,OAAO,IAAI,CAAC,KAAK,CAAC,IAAI,CAAkB,CAAC;QAC3C,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,OAAO,CAAC,MAAM,EAAE,cAAc,EAAE,CAAC;YACjC,IAAK,KAA+B,CAAC,IAAI,KAAK,QAAQ;gBAAE,OAAO,IAAI,CAAC;YACpE,MAAM,KAAK,CAAC;QACd,CAAC;IACH,CAAC;IACD,KAAK,CAAC,IAAI,CAAC,QAAuB,EAAE,UAA4B,EAAE;QAChE,OAAO,CAAC,MAAM,EAAE,cAAc,EAAE,CAAC;QACjC,MAAM,IAAI,GAAG,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,CAAC;QACtC,MAAM,SAAS,GAAG,GAAG,IAAI,CAAC,IAAI,IAAI,UAAU,EAAE,MAAM,CAAC;QACrD,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,SAAS,EAAE,IAAI,EAAE,KAAK,CAAC,CAAC;QAClD,IAAI,MAAM,GAAG,KAAK,CAAC;QAAC,IAAI,SAAS,GAAG,KAAK,CAAC;QAC1C,IAAI,CAAC;YACH,MAAM,MAAM,CAAC,SAAS,CAAC,IAAI,EAAE,EAAE,QAAQ,EAAE,MAAM,EAAE,GAAG,OAAO,EAAE,CAAC,CAAC;YAC/D,MAAM,MAAM,CAAC,IAAI,EAAE,CAAC;YAAC,MAAM,MAAM,CAAC,KAAK,EAAE,CAAC;YAAC,MAAM,GAAG,IAAI,CAAC;YACzD,OAAO,CAAC,MAAM,EAAE,cAAc,EAAE,CAAC;YACjC,iFAAiF;YACjF,MAAM,MAAM,CAAC,SAAS,EAAE,IAAI,CAAC,IAAI,CAAC,CAAC;YAAC,SAAS,GAAG,IAAI,CAAC;QACvD,CAAC;gBAAS,CAAC;YACT,IAAI,CAAC;gBAAC,IAAI,CAAC,MAAM;oBAAE,MAAM,MAAM,CAAC,KAAK,EAAE,CAAC;YAAC,CAAC;oBAClC,CAAC;gBAAC,IAAI,CAAC,SAAS;oBAAE,MAAM,MAAM,CAAC,SAAS,CAAC,CAAC;YAAC,CAAC;QACtD,CAAC;IACH,CAAC;CACF","sourcesContent":["import { open, readFile, rename, unlink } from 'node:fs/promises';\nimport { isAbsolute } from 'node:path';\nimport { randomUUID } from 'node:crypto';\nimport type { IndexSnapshot, IndexStorage, OperationOptions } from '../contracts.js';\n/** Atomic single-writer store at an explicit host-chosen absolute path. Parent must exist. */\nexport class FileIndexStorage implements IndexStorage {\n constructor(private readonly path: string) {\n if (!isAbsolute(path)) throw new Error('An explicit absolute index path is required');\n }\n async load(options: OperationOptions = {}): Promise<IndexSnapshot | null> {\n options.signal?.throwIfAborted();\n try {\n const text = await readFile(this.path, { encoding: 'utf8', ...options });\n options.signal?.throwIfAborted();\n return JSON.parse(text) as IndexSnapshot;\n } catch (error) {\n options.signal?.throwIfAborted();\n if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;\n throw error;\n }\n }\n async save(snapshot: IndexSnapshot, options: OperationOptions = {}): Promise<void> {\n options.signal?.throwIfAborted();\n const text = JSON.stringify(snapshot);\n const temporary = `${this.path}.${randomUUID()}.tmp`;\n const handle = await open(temporary, 'wx', 0o600);\n let closed = false; let published = false;\n try {\n await handle.writeFile(text, { encoding: 'utf8', ...options });\n await handle.sync(); await handle.close(); closed = true;\n options.signal?.throwIfAborted();\n // rename is the publication boundary; cancellation after this point is too late.\n await rename(temporary, this.path); published = true;\n } finally {\n try { if (!closed) await handle.close(); }\n finally { if (!published) await unlink(temporary); }\n }\n }\n}\n"]}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { OperationOptions, SearchSource, SourceDocument, SourceContent } from '../contracts.js';
|
|
2
|
+
import { type FileTypeRegistry } from '../file-types/registry.js';
|
|
3
|
+
export interface FileSystemSourceOptions {
|
|
4
|
+
id: string;
|
|
5
|
+
root: string;
|
|
6
|
+
maxFileBytes?: number;
|
|
7
|
+
fileTypes?: FileTypeRegistry;
|
|
8
|
+
}
|
|
9
|
+
/** Explicit root only. No symlinks (including internal ones) are followed in this first adapter. */
|
|
10
|
+
export declare class FileSystemFolderSource implements SearchSource {
|
|
11
|
+
readonly type = "filesystem";
|
|
12
|
+
readonly id: string;
|
|
13
|
+
private readonly root;
|
|
14
|
+
private readonly maxBytes;
|
|
15
|
+
private readonly fileTypes;
|
|
16
|
+
private canonicalRoot;
|
|
17
|
+
constructor(options: FileSystemSourceOptions);
|
|
18
|
+
private getRoot;
|
|
19
|
+
private checkedPath;
|
|
20
|
+
list(options?: OperationOptions): Promise<SourceDocument[]>;
|
|
21
|
+
read(documentId: string, options?: OperationOptions): Promise<SourceContent>;
|
|
22
|
+
}
|
|
23
|
+
export { FileIndexStorage } from './file-storage.js';
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { lstat, opendir, realpath, open } from 'node:fs/promises';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { resolve, relative, isAbsolute, sep } from 'node:path';
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
import { defaultFileTypes } from '../file-types/registry.js';
|
|
6
|
+
/** Explicit root only. No symlinks (including internal ones) are followed in this first adapter. */
|
|
7
|
+
export class FileSystemFolderSource {
|
|
8
|
+
type = 'filesystem';
|
|
9
|
+
id;
|
|
10
|
+
root;
|
|
11
|
+
maxBytes;
|
|
12
|
+
fileTypes;
|
|
13
|
+
canonicalRoot;
|
|
14
|
+
constructor(options) {
|
|
15
|
+
if (!options.id.trim() || !isAbsolute(options.root))
|
|
16
|
+
throw new Error('A source ID and explicit absolute root are required');
|
|
17
|
+
this.fileTypes = options.fileTypes ?? defaultFileTypes;
|
|
18
|
+
this.id = options.id;
|
|
19
|
+
this.root = resolve(options.root);
|
|
20
|
+
this.maxBytes = options.maxFileBytes ?? 2 * 1024 * 1024;
|
|
21
|
+
if (!Number.isSafeInteger(this.maxBytes) || this.maxBytes < 1)
|
|
22
|
+
throw new Error('Invalid file size limit');
|
|
23
|
+
}
|
|
24
|
+
async getRoot() {
|
|
25
|
+
const root = await realpath(this.root);
|
|
26
|
+
if (!(await lstat(root)).isDirectory())
|
|
27
|
+
throw new Error('Source root is not a directory');
|
|
28
|
+
if (this.canonicalRoot && this.canonicalRoot !== root)
|
|
29
|
+
throw new Error('Source root changed; register it again explicitly');
|
|
30
|
+
this.canonicalRoot = root;
|
|
31
|
+
return root;
|
|
32
|
+
}
|
|
33
|
+
async checkedPath(id) {
|
|
34
|
+
if (!id || id.includes('\0') || id.includes('\\') || isAbsolute(id) || id.split('/').some(part => !part || part === '.' || part === '..'))
|
|
35
|
+
throw new Error('Invalid document path');
|
|
36
|
+
const root = await this.getRoot();
|
|
37
|
+
const candidate = resolve(root, id);
|
|
38
|
+
const inside = relative(root, candidate);
|
|
39
|
+
if (!inside || inside === '..' || inside.startsWith('..' + sep) || isAbsolute(inside))
|
|
40
|
+
throw new Error('Path escapes source root');
|
|
41
|
+
let current = root;
|
|
42
|
+
for (const part of id.split('/')) {
|
|
43
|
+
current = resolve(current, part);
|
|
44
|
+
if ((await lstat(current)).isSymbolicLink())
|
|
45
|
+
throw new Error('Symlinks are not followed');
|
|
46
|
+
}
|
|
47
|
+
if (await realpath(candidate) !== candidate)
|
|
48
|
+
throw new Error('Path changed or escaped its root');
|
|
49
|
+
return candidate;
|
|
50
|
+
}
|
|
51
|
+
async list(options = {}) {
|
|
52
|
+
const root = await this.getRoot();
|
|
53
|
+
const documents = [];
|
|
54
|
+
const walk = async (folder) => {
|
|
55
|
+
options.signal?.throwIfAborted();
|
|
56
|
+
for await (const entry of await opendir(folder)) {
|
|
57
|
+
options.signal?.throwIfAborted();
|
|
58
|
+
if (entry.isSymbolicLink())
|
|
59
|
+
continue;
|
|
60
|
+
const path = resolve(folder, entry.name);
|
|
61
|
+
const id = relative(root, path).split(sep).join('/');
|
|
62
|
+
const checked = await this.checkedPath(id);
|
|
63
|
+
if (entry.isDirectory()) {
|
|
64
|
+
await walk(checked);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const definition = this.fileTypes.get(entry.name);
|
|
68
|
+
if (!entry.isFile() || !definition?.indexable)
|
|
69
|
+
continue;
|
|
70
|
+
const stat = await lstat(checked);
|
|
71
|
+
if (!stat.isFile() || stat.size > Math.min(this.maxBytes, definition.maxBytes))
|
|
72
|
+
continue;
|
|
73
|
+
documents.push({ id, sourceId: this.id, uri: pathToFileURL(checked).href, path: id, name: entry.name,
|
|
74
|
+
createdAt: stat.birthtimeMs, modifiedAt: stat.mtimeMs });
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
await walk(root);
|
|
78
|
+
return documents.sort((a, b) => a.id.localeCompare(b.id));
|
|
79
|
+
}
|
|
80
|
+
async read(documentId, options = {}) {
|
|
81
|
+
options.signal?.throwIfAborted();
|
|
82
|
+
const type = this.fileTypes.get(documentId);
|
|
83
|
+
if (!type?.indexable)
|
|
84
|
+
throw new Error('Unsupported file type');
|
|
85
|
+
const path = await this.checkedPath(documentId);
|
|
86
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
87
|
+
try {
|
|
88
|
+
const stat = await handle.stat();
|
|
89
|
+
const max = Math.min(this.maxBytes, type.maxBytes);
|
|
90
|
+
if (!stat.isFile() || stat.size > max)
|
|
91
|
+
throw new Error('File is not indexable or exceeds the size limit');
|
|
92
|
+
const buffer = new Uint8Array(max + 1);
|
|
93
|
+
let count = 0;
|
|
94
|
+
while (count <= max) {
|
|
95
|
+
options.signal?.throwIfAborted();
|
|
96
|
+
const { bytesRead } = await handle.read(buffer, count, buffer.length - count, null);
|
|
97
|
+
if (!bytesRead)
|
|
98
|
+
break;
|
|
99
|
+
count += bytesRead;
|
|
100
|
+
}
|
|
101
|
+
if (count > max)
|
|
102
|
+
throw new Error('File exceeds the size limit');
|
|
103
|
+
// Recheck containment and identity before returning bytes if the source changed during I/O.
|
|
104
|
+
const current = await lstat(await this.checkedPath(documentId));
|
|
105
|
+
const after = await handle.stat();
|
|
106
|
+
if (current.ino !== stat.ino || current.dev !== stat.dev || after.mtimeMs !== stat.mtimeMs || after.size !== stat.size)
|
|
107
|
+
throw new Error('File changed while reading; retry');
|
|
108
|
+
const text = new TextDecoder('utf-8', { fatal: true }).decode(buffer.subarray(0, count));
|
|
109
|
+
if (text.includes('\0'))
|
|
110
|
+
throw new Error('Binary content is not indexable');
|
|
111
|
+
return { text };
|
|
112
|
+
}
|
|
113
|
+
finally {
|
|
114
|
+
await handle.close();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
export { FileIndexStorage } from './file-storage.js';
|
|
119
|
+
//# sourceMappingURL=filesystem.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"filesystem.js","sourceRoot":"","sources":["../../src/adapters/filesystem.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAC;AAClE,OAAO,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AACpC,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,UAAU,EAAE,GAAG,EAAE,MAAM,WAAW,CAAC;AAC/D,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAEzC,OAAO,EAAE,gBAAgB,EAAyB,MAAM,2BAA2B,CAAC;AAEpF,oGAAoG;AACpG,MAAM,OAAO,sBAAsB;IACxB,IAAI,GAAG,YAAY,CAAC;IACpB,EAAE,CAAS;IACH,IAAI,CAAS;IACb,QAAQ,CAAS;IACjB,SAAS,CAAmB;IACrC,aAAa,CAAqB;IAC1C,YAAY,OAAgC;QAC1C,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC,IAAI,EAAE,IAAI,CAAC,UAAU,CAAC,OAAO,CAAC,IAAI,CAAC;YAAE,MAAM,IAAI,KAAK,CAAC,qDAAqD,CAAC,CAAC;QAC5H,IAAI,CAAC,SAAS,GAAG,OAAO,CAAC,SAAS,IAAI,gBAAgB,CAAC;QACvD,IAAI,CAAC,EAAE,GAAG,OAAO,CAAC,EAAE,CAAC;QAAC,IAAI,CAAC,IAAI,GAAG,OAAO,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;QAAC,IAAI,CAAC,QAAQ,GAAG,OAAO,CAAC,YAAY,IAAI,CAAC,GAAG,IAAI,GAAG,IAAI,CAAC;QACjH,IAAI,CAAC,MAAM,CAAC,aAAa,CAAC,IAAI,CAAC,QAAQ,CAAC,IAAI,IAAI,CAAC,QAAQ,GAAG,CAAC;YAAE,MAAM,IAAI,KAAK,CAAC,yBAAyB,CAAC,CAAC;IAC5G,CAAC;IACO,KAAK,CAAC,OAAO;QACnB,MAAM,IAAI,GAAG,MAAM,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QACvC,IAAI,CAAC,CAAC,MAAM,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,WAAW,EAAE;YAAE,MAAM,IAAI,KAAK,CAAC,gCAAgC,CAAC,CAAC;QAC1F,IAAI,IAAI,CAAC,aAAa,IAAI,IAAI,CAAC,aAAa,KAAK,IAAI;YAAE,MAAM,IAAI,KAAK,CAAC,mDAAmD,CAAC,CAAC;QAC5H,IAAI,CAAC,aAAa,GAAG,IAAI,CAAC;QAAC,OAAO,IAAI,CAAC;IACzC,CAAC;IACO,KAAK,CAAC,WAAW,CAAC,EAAU;QAClC,IAAI,CAAC,EAAE,IAAI,EAAE,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,IAAI,IAAI,IAAI,KAAK,GAAG,IAAI,IAAI,KAAK,IAAI,CAAC;YAAE,MAAM,IAAI,KAAK,CAAC,uBAAuB,CAAC,CAAC;QACpL,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,OAAO,EAAE,CAAC;QAAC,MAAM,SAAS,GAAG,OAAO,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC;QACvE,MAAM,MAAM,GAAG,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC,CAAC;QACzC,IAAI,CAAC,MAAM,IAAI,MAAM,KAAK,IAAI,IAAI,MAAM,CAAC,UAAU,CAAC,IAAI,GAAG,GAAG,CAAC,IAAI,UAAU,CAAC,MAAM,CAAC;YAAE,MAAM,IAAI,KAAK,CAAC,0BAA0B,CAAC,CAAC;QACnI,IAAI,OAAO,GAAG,IAAI,CAAC;QACnB,KAAK,MAAM,IAAI,IAAI,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,EAAE,CAAC;YAAC,OAAO,GAAG,OAAO,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC;YAAC,IAAI,CAAC,MAAM,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,cAAc,EAAE;gBAAE,MAAM,IAAI,KAAK,CAAC,2BAA2B,CAAC,CAAC;QAAC,CAAC;QAClK,IAAI,MAAM,QAAQ,CAAC,SAAS,CAAC,KAAK,SAAS;YAAE,MAAM,IAAI,KAAK,CAAC,kCAAkC,CAAC,CAAC;QACjG,OAAO,SAAS,CAAC;IACnB,CAAC;IACD,KAAK,CAAC,IAAI,CAAC,UAA4B,EAAE;QACvC,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,OAAO,EAAE,CAAC;QAAC,MAAM,SAAS,GAAqB,EAAE,CAAC;QAC1E,MAAM,IAAI,GAAG,KAAK,EAAE,MAAc,EAAiB,EAAE;YACnD,OAAO,CAAC,MAAM,EAAE,cAAc,EAAE,CAAC;YACjC,IAAI,KAAK,EAAE,MAAM,KAAK,IAAI,MAAM,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC;gBAChD,OAAO,CAAC,MAAM,EAAE,cAAc,EAAE,CAAC;gBAAC,IAAI,KAAK,CAAC,cAAc,EAAE;oBAAE,SAAS;gBACvE,MAAM,IAAI,GAAG,OAAO,CAAC,MAAM,EAAE,KAAK,CAAC,IAAI,CAAC,CAAC;gBAAC,MAAM,EAAE,GAAG,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;gBAC/F,MAAM,OAAO,GAAG,MAAM,IAAI,CAAC,WAAW,CAAC,EAAE,CAAC,CAAC;gBAC3C,IAAI,KAAK,CAAC,WAAW,EAAE,EAAE,CAAC;oBAAC,MAAM,IAAI,CAAC,OAAO,CAAC,CAAC;oBAAC,SAAS;gBAAC,CAAC;gBAC3D,MAAM,UAAU,GAAG,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;gBAClD,IAAI,CAAC,KAAK,CAAC,MAAM,EAAE,IAAI,CAAC,UAAU,EAAE,SAAS;oBAAE,SAAS;gBACxD,MAAM,IAAI,GAAG,MAAM,KAAK,CAAC,OAAO,CAAC,CAAC;gBAAC,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE,IAAI,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,QAAQ,EAAE,UAAU,CAAC,QAAQ,CAAC;oBAAE,SAAS;gBAC5H,SAAS,CAAC,IAAI,CAAC,EAAE,EAAE,EAAE,QAAQ,EAAE,IAAI,CAAC,EAAE,EAAE,GAAG,EAAE,aAAa,CAAC,OAAO,CAAC,CAAC,IAAI,EAAE,IAAI,EAAE,EAAE,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI;oBAClG,SAAS,EAAE,IAAI,CAAC,WAAW,EAAE,UAAU,EAAE,IAAI,CAAC,OAAO,EAAE,CAAC,CAAC;YAC7D,CAAC;QACH,CAAC,CAAC;QACF,MAAM,IAAI,CAAC,IAAI,CAAC,CAAC;QAAC,OAAO,SAAS,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,aAAa,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;IAC9E,CAAC;IACD,KAAK,CAAC,IAAI,CAAC,UAAkB,EAAE,UAA4B,EAAE;QAC3D,OAAO,CAAC,MAAM,EAAE,cAAc,EAAE,CAAC;QACjC,MAAM,IAAI,GAAG,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC;QAAC,IAAI,CAAC,IAAI,EAAE,SAAS;YAAE,MAAM,IAAI,KAAK,CAAC,uBAAuB,CAAC,CAAC;QAC5G,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,WAAW,CAAC,UAAU,CAAC,CAAC;QAChD,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,IAAI,EAAE,SAAS,CAAC,QAAQ,GAAG,SAAS,CAAC,UAAU,CAAC,CAAC;QAC3E,IAAI,CAAC;YACH,MAAM,IAAI,GAAG,MAAM,MAAM,CAAC,IAAI,EAAE,CAAC;YAAC,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,QAAQ,EAAE,IAAI,CAAC,QAAQ,CAAC,CAAC;YACrF,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE,IAAI,IAAI,CAAC,IAAI,GAAG,GAAG;gBAAE,MAAM,IAAI,KAAK,CAAC,iDAAiD,CAAC,CAAC;YAC1G,MAAM,MAAM,GAAG,IAAI,UAAU,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC;YAAC,IAAI,KAAK,GAAG,CAAC,CAAC;YACtD,OAAO,KAAK,IAAI,GAAG,EAAE,CAAC;gBACpB,OAAO,CAAC,MAAM,EAAE,cAAc,EAAE,CAAC;gBACjC,MAAM,EAAE,SAAS,EAAE,GAAG,MAAM,MAAM,CAAC,IAAI,CAAC,MAAM,EAAE,KAAK,EAAE,MAAM,CAAC,MAAM,GAAG,KAAK,EAAE,IAAI,CAAC,CAAC;gBACpF,IAAI,CAAC,SAAS;oBAAE,MAAM;gBAAC,KAAK,IAAI,SAAS,CAAC;YAC5C,CAAC;YACD,IAAI,KAAK,GAAG,GAAG;gBAAE,MAAM,IAAI,KAAK,CAAC,6BAA6B,CAAC,CAAC;YAChE,4FAA4F;YAC5F,MAAM,OAAO,GAAG,MAAM,KAAK,CAAC,MAAM,IAAI,CAAC,WAAW,CAAC,UAAU,CAAC,CAAC,CAAC;YAAC,MAAM,KAAK,GAAG,MAAM,MAAM,CAAC,IAAI,EAAE,CAAC;YACnG,IAAI,OAAO,CAAC,GAAG,KAAK,IAAI,CAAC,GAAG,IAAI,OAAO,CAAC,GAAG,KAAK,IAAI,CAAC,GAAG,IAAI,KAAK,CAAC,OAAO,KAAK,IAAI,CAAC,OAAO,IAAI,KAAK,CAAC,IAAI,KAAK,IAAI,CAAC,IAAI;gBAAE,MAAM,IAAI,KAAK,CAAC,mCAAmC,CAAC,CAAC;YAC7K,MAAM,IAAI,GAAG,IAAI,WAAW,CAAC,OAAO,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,EAAE,KAAK,CAAC,CAAC,CAAC;YACzF,IAAI,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC;gBAAE,MAAM,IAAI,KAAK,CAAC,iCAAiC,CAAC,CAAC;YAC5E,OAAO,EAAE,IAAI,EAAE,CAAC;QAClB,CAAC;gBAAS,CAAC;YAAC,MAAM,MAAM,CAAC,KAAK,EAAE,CAAC;QAAC,CAAC;IACrC,CAAC;CACF;AAED,OAAO,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC","sourcesContent":["import { lstat, opendir, realpath, open } from 'node:fs/promises';\nimport { constants } from 'node:fs';\nimport { resolve, relative, isAbsolute, sep } from 'node:path';\nimport { pathToFileURL } from 'node:url';\nimport type { OperationOptions, SearchSource, SourceDocument, SourceContent } from '../contracts.js';\nimport { defaultFileTypes, type FileTypeRegistry } from '../file-types/registry.js';\nexport interface FileSystemSourceOptions { id: string; root: string; maxFileBytes?: number; fileTypes?: FileTypeRegistry; }\n/** Explicit root only. No symlinks (including internal ones) are followed in this first adapter. */\nexport class FileSystemFolderSource implements SearchSource {\n readonly type = 'filesystem';\n readonly id: string;\n private readonly root: string;\n private readonly maxBytes: number;\n private readonly fileTypes: FileTypeRegistry;\n private canonicalRoot: string | undefined;\n constructor(options: FileSystemSourceOptions) {\n if (!options.id.trim() || !isAbsolute(options.root)) throw new Error('A source ID and explicit absolute root are required');\n this.fileTypes = options.fileTypes ?? defaultFileTypes;\n this.id = options.id; this.root = resolve(options.root); this.maxBytes = options.maxFileBytes ?? 2 * 1024 * 1024;\n if (!Number.isSafeInteger(this.maxBytes) || this.maxBytes < 1) throw new Error('Invalid file size limit');\n }\n private async getRoot(): Promise<string> {\n const root = await realpath(this.root);\n if (!(await lstat(root)).isDirectory()) throw new Error('Source root is not a directory');\n if (this.canonicalRoot && this.canonicalRoot !== root) throw new Error('Source root changed; register it again explicitly');\n this.canonicalRoot = root; return root;\n }\n private async checkedPath(id: string): Promise<string> {\n if (!id || id.includes('\\0') || id.includes('\\\\') || isAbsolute(id) || id.split('/').some(part => !part || part === '.' || part === '..')) throw new Error('Invalid document path');\n const root = await this.getRoot(); const candidate = resolve(root, id);\n const inside = relative(root, candidate);\n if (!inside || inside === '..' || inside.startsWith('..' + sep) || isAbsolute(inside)) throw new Error('Path escapes source root');\n let current = root;\n for (const part of id.split('/')) { current = resolve(current, part); if ((await lstat(current)).isSymbolicLink()) throw new Error('Symlinks are not followed'); }\n if (await realpath(candidate) !== candidate) throw new Error('Path changed or escaped its root');\n return candidate;\n }\n async list(options: OperationOptions = {}): Promise<SourceDocument[]> {\n const root = await this.getRoot(); const documents: SourceDocument[] = [];\n const walk = async (folder: string): Promise<void> => {\n options.signal?.throwIfAborted();\n for await (const entry of await opendir(folder)) {\n options.signal?.throwIfAborted(); if (entry.isSymbolicLink()) continue;\n const path = resolve(folder, entry.name); const id = relative(root, path).split(sep).join('/');\n const checked = await this.checkedPath(id);\n if (entry.isDirectory()) { await walk(checked); continue; }\n const definition = this.fileTypes.get(entry.name);\n if (!entry.isFile() || !definition?.indexable) continue;\n const stat = await lstat(checked); if (!stat.isFile() || stat.size > Math.min(this.maxBytes, definition.maxBytes)) continue;\n documents.push({ id, sourceId: this.id, uri: pathToFileURL(checked).href, path: id, name: entry.name,\n createdAt: stat.birthtimeMs, modifiedAt: stat.mtimeMs });\n }\n };\n await walk(root); return documents.sort((a, b) => a.id.localeCompare(b.id));\n }\n async read(documentId: string, options: OperationOptions = {}): Promise<SourceContent> {\n options.signal?.throwIfAborted();\n const type = this.fileTypes.get(documentId); if (!type?.indexable) throw new Error('Unsupported file type');\n const path = await this.checkedPath(documentId);\n const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);\n try {\n const stat = await handle.stat(); const max = Math.min(this.maxBytes, type.maxBytes);\n if (!stat.isFile() || stat.size > max) throw new Error('File is not indexable or exceeds the size limit');\n const buffer = new Uint8Array(max + 1); let count = 0;\n while (count <= max) {\n options.signal?.throwIfAborted();\n const { bytesRead } = await handle.read(buffer, count, buffer.length - count, null);\n if (!bytesRead) break; count += bytesRead;\n }\n if (count > max) throw new Error('File exceeds the size limit');\n // Recheck containment and identity before returning bytes if the source changed during I/O.\n const current = await lstat(await this.checkedPath(documentId)); const after = await handle.stat();\n if (current.ino !== stat.ino || current.dev !== stat.dev || after.mtimeMs !== stat.mtimeMs || after.size !== stat.size) throw new Error('File changed while reading; retry');\n const text = new TextDecoder('utf-8', { fatal: true }).decode(buffer.subarray(0, count));\n if (text.includes('\\0')) throw new Error('Binary content is not indexable');\n return { text };\n } finally { await handle.close(); }\n }\n}\n\nexport { FileIndexStorage } from './file-storage.js';\n"]}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Embedder, OperationOptions } from '../contracts.js';
|
|
2
|
+
export declare const MINILM_DEFAULT_MODEL = "Xenova/all-MiniLM-L6-v2";
|
|
3
|
+
export declare const MINILM_DEFAULT_REVISION = "751bff37182d3f1213fa05d7196b954e230abad9";
|
|
4
|
+
export interface MiniLMEmbedderOptions {
|
|
5
|
+
/** Custom/local model must produce 384-dimensional sentence embeddings; supply its revision explicitly. */
|
|
6
|
+
model?: string;
|
|
7
|
+
revision?: string;
|
|
8
|
+
batchSize?: number;
|
|
9
|
+
/** Node default is cpu. Browser/worker hosts select wasm or supported webgpu explicitly. */
|
|
10
|
+
device?: 'cpu' | 'wasm' | 'webgpu';
|
|
11
|
+
dtype?: 'fp32' | 'fp16' | 'q8';
|
|
12
|
+
/** Forwarded to Transformers.js; Hub-ID metadata probes in 4.2.0 may still access the network.
|
|
13
|
+
* Strict offline operation requires host-staged assets and host env.allowRemoteModels=false.
|
|
14
|
+
*/
|
|
15
|
+
localFilesOnly?: boolean;
|
|
16
|
+
/** Passed to the runtime; a populated Hub cache alone does not guarantee offline resolution. */
|
|
17
|
+
cacheDir?: string;
|
|
18
|
+
onProgress?: (event: {
|
|
19
|
+
status: string;
|
|
20
|
+
file?: string;
|
|
21
|
+
progress?: number;
|
|
22
|
+
loaded?: number;
|
|
23
|
+
total?: number;
|
|
24
|
+
}) => void;
|
|
25
|
+
}
|
|
26
|
+
/** Optional local sentence embedder. Import/construction never load the runtime or model.
|
|
27
|
+
* Hosts own placement and disposal. Calls are serialized; cancellation cannot preempt native inference.
|
|
28
|
+
*/
|
|
29
|
+
export declare class MiniLMEmbedder implements Embedder {
|
|
30
|
+
readonly dimensions = 384;
|
|
31
|
+
readonly id: string;
|
|
32
|
+
private readonly options;
|
|
33
|
+
private readonly model;
|
|
34
|
+
private readonly revision;
|
|
35
|
+
private readonly batchSize;
|
|
36
|
+
private readonly device;
|
|
37
|
+
private readonly dtype;
|
|
38
|
+
private extractor;
|
|
39
|
+
private tail;
|
|
40
|
+
private disposed;
|
|
41
|
+
private disposal;
|
|
42
|
+
constructor(options?: MiniLMEmbedderOptions);
|
|
43
|
+
private check;
|
|
44
|
+
private load;
|
|
45
|
+
embedDocuments(texts: string[], options?: OperationOptions): Promise<Float32Array[]>;
|
|
46
|
+
embedQuery(text: string, options?: OperationOptions): Promise<Float32Array>;
|
|
47
|
+
private run;
|
|
48
|
+
/** Terminal and idempotent. Waits for active work, releases the pipeline; further use rejects. */
|
|
49
|
+
dispose(): Promise<void>;
|
|
50
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
export const MINILM_DEFAULT_MODEL = 'Xenova/all-MiniLM-L6-v2';
|
|
2
|
+
export const MINILM_DEFAULT_REVISION = '751bff37182d3f1213fa05d7196b954e230abad9';
|
|
3
|
+
const RUNTIME_VERSION = '4.2.0';
|
|
4
|
+
/** Optional local sentence embedder. Import/construction never load the runtime or model.
|
|
5
|
+
* Hosts own placement and disposal. Calls are serialized; cancellation cannot preempt native inference.
|
|
6
|
+
*/
|
|
7
|
+
export class MiniLMEmbedder {
|
|
8
|
+
dimensions = 384;
|
|
9
|
+
id;
|
|
10
|
+
options;
|
|
11
|
+
model;
|
|
12
|
+
revision;
|
|
13
|
+
batchSize;
|
|
14
|
+
device;
|
|
15
|
+
dtype;
|
|
16
|
+
extractor;
|
|
17
|
+
tail = Promise.resolve();
|
|
18
|
+
disposed = false;
|
|
19
|
+
disposal;
|
|
20
|
+
constructor(options = {}) {
|
|
21
|
+
this.options = { ...options };
|
|
22
|
+
this.model = options.model ?? MINILM_DEFAULT_MODEL;
|
|
23
|
+
this.revision = options.revision ?? MINILM_DEFAULT_REVISION;
|
|
24
|
+
this.batchSize = options.batchSize ?? 4;
|
|
25
|
+
this.device = options.device ?? 'cpu';
|
|
26
|
+
this.dtype = options.dtype ?? 'fp32';
|
|
27
|
+
if (!this.model.trim() || !this.revision.trim() || (this.model !== MINILM_DEFAULT_MODEL && !options.revision))
|
|
28
|
+
throw new Error('A custom MiniLM model requires an explicit revision');
|
|
29
|
+
if (!Number.isSafeInteger(this.batchSize) || this.batchSize < 1)
|
|
30
|
+
throw new Error('Invalid MiniLM batch size');
|
|
31
|
+
if (!['cpu', 'wasm', 'webgpu'].includes(this.device) || !['fp32', 'fp16', 'q8'].includes(this.dtype))
|
|
32
|
+
throw new Error('Unsupported MiniLM device or dtype');
|
|
33
|
+
this.id = JSON.stringify({ adapter: 'minilm-v1', runtime: RUNTIME_VERSION, model: this.model, revision: this.revision,
|
|
34
|
+
device: this.device, dtype: this.dtype, pooling: 'mean', normalize: true, truncation: 'pinned-tokenizer-default' });
|
|
35
|
+
}
|
|
36
|
+
check(options) {
|
|
37
|
+
options.signal?.throwIfAborted();
|
|
38
|
+
if (this.disposed)
|
|
39
|
+
throw new Error('MiniLMEmbedder is disposed');
|
|
40
|
+
}
|
|
41
|
+
async load(options) {
|
|
42
|
+
this.check(options);
|
|
43
|
+
if (!this.extractor) {
|
|
44
|
+
try {
|
|
45
|
+
const runtime = await import('@huggingface/transformers');
|
|
46
|
+
this.check(options);
|
|
47
|
+
if (runtime.env.version !== RUNTIME_VERSION)
|
|
48
|
+
throw new Error(`Expected @huggingface/transformers ${RUNTIME_VERSION}, received ${runtime.env.version}`);
|
|
49
|
+
const loading = {
|
|
50
|
+
revision: this.revision, device: this.device, dtype: this.dtype,
|
|
51
|
+
...(this.options.cacheDir !== undefined ? { cache_dir: this.options.cacheDir } : {}),
|
|
52
|
+
...(this.options.localFilesOnly !== undefined ? { local_files_only: this.options.localFilesOnly } : {}),
|
|
53
|
+
...(this.options.onProgress ? { progress_callback: (event) => this.options.onProgress({
|
|
54
|
+
status: event.status,
|
|
55
|
+
...('file' in event ? { file: event.file } : {}),
|
|
56
|
+
...('progress' in event ? { progress: event.progress } : {}),
|
|
57
|
+
...('loaded' in event ? { loaded: event.loaded } : {}),
|
|
58
|
+
...('total' in event ? { total: event.total } : {}),
|
|
59
|
+
}) } : {}),
|
|
60
|
+
};
|
|
61
|
+
// Avoid factory preflight; the 4.2.0 tokenizer still probes unversioned metadata.
|
|
62
|
+
// Strict offline hosts stage local assets and configure the runtime globally (see integration docs).
|
|
63
|
+
const tokenizer = await runtime.AutoTokenizer.from_pretrained(this.model, loading);
|
|
64
|
+
this.check(options);
|
|
65
|
+
const model = await runtime.AutoModel.from_pretrained(this.model, loading);
|
|
66
|
+
try {
|
|
67
|
+
this.extractor = new runtime.FeatureExtractionPipeline({ task: 'feature-extraction', tokenizer, model });
|
|
68
|
+
}
|
|
69
|
+
catch (cause) {
|
|
70
|
+
await model.dispose();
|
|
71
|
+
throw cause;
|
|
72
|
+
}
|
|
73
|
+
this.options.onProgress?.({ status: 'ready' });
|
|
74
|
+
}
|
|
75
|
+
catch (cause) {
|
|
76
|
+
this.check(options);
|
|
77
|
+
throw new Error(`MiniLM model load failed (${this.model}@${this.revision}). Install @huggingface/transformers@${RUNTIME_VERSION} and check model/cache availability.`, { cause });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
this.check(options);
|
|
81
|
+
return this.extractor;
|
|
82
|
+
}
|
|
83
|
+
async embedDocuments(texts, options = {}) {
|
|
84
|
+
this.check(options);
|
|
85
|
+
if (!Array.isArray(texts) || texts.some(text => typeof text !== 'string'))
|
|
86
|
+
throw new Error('MiniLM inputs must be strings');
|
|
87
|
+
const inputs = [...texts];
|
|
88
|
+
const operationOptions = { ...options };
|
|
89
|
+
const operation = this.tail.then(() => this.run(inputs, operationOptions));
|
|
90
|
+
this.tail = operation.then(() => { }, () => { });
|
|
91
|
+
return operation;
|
|
92
|
+
}
|
|
93
|
+
async embedQuery(text, options = {}) {
|
|
94
|
+
return (await this.embedDocuments([text], options))[0];
|
|
95
|
+
}
|
|
96
|
+
async run(texts, options) {
|
|
97
|
+
this.check(options);
|
|
98
|
+
if (!texts.length)
|
|
99
|
+
return [];
|
|
100
|
+
const extractor = await this.load(options);
|
|
101
|
+
const vectors = [];
|
|
102
|
+
for (let start = 0; start < texts.length; start += this.batchSize) {
|
|
103
|
+
this.check(options);
|
|
104
|
+
const batch = texts.slice(start, start + this.batchSize);
|
|
105
|
+
const output = await extractor(batch, { pooling: 'mean', normalize: true });
|
|
106
|
+
try {
|
|
107
|
+
this.check(options);
|
|
108
|
+
const data = output.data;
|
|
109
|
+
if (output.type !== 'float32' || !(data instanceof Float32Array) || output.dims.length !== 2 ||
|
|
110
|
+
output.dims[0] !== batch.length || output.dims[1] !== this.dimensions || data.length !== batch.length * this.dimensions)
|
|
111
|
+
throw new Error('Invalid MiniLM output shape/type');
|
|
112
|
+
for (let row = 0; row < batch.length; row++) {
|
|
113
|
+
const vector = data.slice(row * this.dimensions, (row + 1) * this.dimensions);
|
|
114
|
+
const norm = Math.sqrt(vector.reduce((sum, value) => sum + value * value, 0));
|
|
115
|
+
if (!vector.every(Number.isFinite) || Math.abs(norm - 1) > 0.001)
|
|
116
|
+
throw new Error('Invalid MiniLM output: expected finite unit vectors');
|
|
117
|
+
vectors.push(vector);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
finally {
|
|
121
|
+
output.dispose();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
this.check(options);
|
|
125
|
+
return vectors;
|
|
126
|
+
}
|
|
127
|
+
/** Terminal and idempotent. Waits for active work, releases the pipeline; further use rejects. */
|
|
128
|
+
dispose() {
|
|
129
|
+
if (!this.disposal) {
|
|
130
|
+
this.disposed = true;
|
|
131
|
+
this.disposal = this.tail.then(async () => {
|
|
132
|
+
const extractor = this.extractor;
|
|
133
|
+
this.extractor = undefined;
|
|
134
|
+
if (extractor)
|
|
135
|
+
await extractor.dispose();
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
return this.disposal;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
//# sourceMappingURL=minilm.js.map
|