@4fu/arbor-journal-sqlite 2026.1106.1 → 2026.1107.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 +6 -1
- package/index.d.ts +4 -4
- package/index.js +3 -3
- package/package.json +9 -8
- package/search.d.ts +13 -18
- package/search.js +14 -29
- package/semantic.d.ts +29 -0
- package/semantic.js +56 -0
- package/sqlite-journal.d.ts +43 -3
- package/sqlite-journal.js +155 -37
- package/segmenter.d.ts +0 -30
- package/segmenter.js +0 -107
package/README.md
CHANGED
|
@@ -56,7 +56,9 @@ npm install @4fu/arbor-core @4fu/arbor-journal-sqlite @4fu/arbor-notes-sqlite \
|
|
|
56
56
|
```
|
|
57
57
|
|
|
58
58
|
`@4fu/arbor-plugin-http` is optional; it is the example plugin below.
|
|
59
|
-
`@4fu/arbor-segmenter-jieba` is an optional peer of the
|
|
59
|
+
`@4fu/arbor-segmenter-jieba` is an optional peer of the lexical package, and
|
|
60
|
+
`@4fu/arbor-search-zvec` an optional peer of the journal: absent either one, the
|
|
61
|
+
journal searches lexically and nothing else changes.
|
|
60
62
|
|
|
61
63
|
## A minimal host
|
|
62
64
|
|
|
@@ -120,6 +122,8 @@ window gets, and how a continuation keeps working in it, are in
|
|
|
120
122
|
| [`@4fu/arbor-journal-sqlite`](packages/journal-sqlite) | the default `Journal` and `BlobStore` on SQLite |
|
|
121
123
|
| [`@4fu/arbor-notes-sqlite`](packages/notes-sqlite) | the default `NoteStore` on SQLite, beside the journal |
|
|
122
124
|
| [`@4fu/arbor-segmenter-jieba`](packages/segmenter-jieba) | the default `Segmenter`: jieba's dictionary segmentation for the word index, optional |
|
|
125
|
+
| [`@4fu/arbor-lexical`](packages/lexical) | the lexical search the journal and notes share: word segmentation, the FTS5 helpers and the merge order |
|
|
126
|
+
| [`@4fu/arbor-search-zvec`](packages/search-zvec) | the optional semantic channel: event text embedded and searched by meaning with zvec |
|
|
123
127
|
| [`@4fu/arbor-workspace-dir`](packages/workspace-dir) | the fallback `Workspace`: one directory per window, with a continuation working in its predecessor's directory in place |
|
|
124
128
|
| [`@4fu/arbor-workspace-ovnfs`](packages/workspace-ovnfs) | the default `Workspace` when ovnfs is installed: one shared folder, per-window copy-on-write checkouts the model opens and applies back |
|
|
125
129
|
| [`@4fu/arbor-environment-dir`](packages/environment-dir) | the default `Environment`: one `/env` directory, a revision per item, validation and deltas |
|
|
@@ -171,6 +175,7 @@ tarball would contain ([docs/release.md](docs/release.md)).
|
|
|
171
175
|
| `packages/errors` | the error shape every refusal uses: the rule and the next action |
|
|
172
176
|
| `packages/journal-sqlite` | the default `Journal` and `BlobStore` on SQLite |
|
|
173
177
|
| `packages/segmenter-jieba` | the default `Segmenter`: jieba's dictionary segmentation for the word index, optional |
|
|
178
|
+
| `packages/lexical` | the lexical search the journal and notes share: word segmentation, the FTS5 helpers and the merge order |
|
|
174
179
|
| `packages/notes-sqlite` | the default `NoteStore` on SQLite, beside the journal |
|
|
175
180
|
| `packages/judge-jev` | the default `Judge`: Jev, TypeSafe AI's System One classifier, answering the dispatch decisions |
|
|
176
181
|
| `packages/workspace-dir` | the default `Workspace`: one directory per window, with a continuation working in its predecessor's directory in place, and the lookup of another window's directory it contributes to the catalog |
|
package/index.d.ts
CHANGED
|
@@ -5,8 +5,8 @@ export { SqliteJournal, deriveLinks, parseEventRef, eventRefOf, summaryOf } from
|
|
|
5
5
|
export type { SqliteJournalOptions } from "./sqlite-journal.ts";
|
|
6
6
|
export { FsBlobStore, blobIdOf, blobRefOf } from "./blob-store.ts";
|
|
7
7
|
export type { BlobEntry, BlobRegistry } from "./blob-store.ts";
|
|
8
|
-
export {
|
|
8
|
+
export { resolveSemanticIndex } from "./semantic.ts";
|
|
9
9
|
export { SCHEMA_VERSION, IMPLICIT_CITE_WEIGHT, TRIGRAM_TABLE, WORD_TABLE, createSchema, } from "./schema.ts";
|
|
10
|
-
export { buildFilters
|
|
11
|
-
export type { FilterSql,
|
|
12
|
-
export { MIN_TRIGRAM_CHARS } from "
|
|
10
|
+
export { buildFilters } from "./search.ts";
|
|
11
|
+
export type { FilterSql, SearchFilters } from "./search.ts";
|
|
12
|
+
export { MIN_TRIGRAM_CHARS } from "@4fu/arbor-lexical";
|
package/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
export { SqliteJournal, deriveLinks, parseEventRef, eventRefOf, summaryOf } from "./sqlite-journal.js";
|
|
5
5
|
export { FsBlobStore, blobIdOf, blobRefOf } from "./blob-store.js";
|
|
6
|
-
export {
|
|
6
|
+
export { resolveSemanticIndex } from "./semantic.js";
|
|
7
7
|
export { SCHEMA_VERSION, IMPLICIT_CITE_WEIGHT, TRIGRAM_TABLE, WORD_TABLE, createSchema, } from "./schema.js";
|
|
8
|
-
export { buildFilters
|
|
9
|
-
export { MIN_TRIGRAM_CHARS } from "
|
|
8
|
+
export { buildFilters } from "./search.js";
|
|
9
|
+
export { MIN_TRIGRAM_CHARS } from "@4fu/arbor-lexical";
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@4fu/arbor-journal-sqlite",
|
|
3
|
-
"version": "2026.
|
|
3
|
+
"version": "2026.1107.1",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "The default Journal: append-only events with a global sequence, targets, native ids, refs, blobs and
|
|
5
|
+
"description": "The default Journal: append-only events with a global sequence, targets, native ids, refs, blobs and search on SQLite: lexical, and semantic when the optional channel is installed.",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": {
|
|
8
8
|
"types": "./index.d.ts",
|
|
@@ -20,8 +20,8 @@
|
|
|
20
20
|
"schema.js",
|
|
21
21
|
"search.d.ts",
|
|
22
22
|
"search.js",
|
|
23
|
-
"
|
|
24
|
-
"
|
|
23
|
+
"semantic.d.ts",
|
|
24
|
+
"semantic.js",
|
|
25
25
|
"sqlite-journal.d.ts",
|
|
26
26
|
"sqlite-journal.js",
|
|
27
27
|
"stored.d.ts",
|
|
@@ -40,14 +40,15 @@
|
|
|
40
40
|
"access": "public"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@4fu/arbor-errors": "2026.
|
|
44
|
-
"@4fu/arbor-interfaces": "2026.
|
|
43
|
+
"@4fu/arbor-errors": "2026.1107.0",
|
|
44
|
+
"@4fu/arbor-interfaces": "2026.1107.1",
|
|
45
|
+
"@4fu/arbor-lexical": "2026.1107.1"
|
|
45
46
|
},
|
|
46
47
|
"peerDependencies": {
|
|
47
|
-
"@4fu/arbor-
|
|
48
|
+
"@4fu/arbor-search-zvec": "2026.1107.1"
|
|
48
49
|
},
|
|
49
50
|
"peerDependenciesMeta": {
|
|
50
|
-
"@4fu/arbor-
|
|
51
|
+
"@4fu/arbor-search-zvec": {
|
|
51
52
|
"optional": true
|
|
52
53
|
}
|
|
53
54
|
}
|
package/search.d.ts
CHANGED
|
@@ -1,30 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Lexical search over the journal's events (docs/journal.md#search).
|
|
3
3
|
*
|
|
4
|
-
* Three indexes,
|
|
4
|
+
* Three indexes, and a fourth channel when a deployment installed one:
|
|
5
5
|
*
|
|
6
6
|
* - a word index, segmented and ranked with BM25;
|
|
7
7
|
* - a trigram index for exact substrings, which is what makes a Chinese
|
|
8
8
|
* substring findable;
|
|
9
9
|
* - a substring scan, for one- and two-character queries and for queries the
|
|
10
|
-
* trigram index cannot answer
|
|
10
|
+
* trigram index cannot answer;
|
|
11
|
+
* - a semantic channel, event text embedded and searched by meaning, when
|
|
12
|
+
* `@4fu/arbor-search-zvec` is installed.
|
|
11
13
|
*
|
|
12
|
-
*
|
|
14
|
+
* What the journal and the notes store share - how a literal is quoted and
|
|
15
|
+
* escaped, how a query becomes one index's match, and how the channels merge -
|
|
16
|
+
* lives in `@4fu/arbor-lexical`. What lives here is what is the journal's own:
|
|
17
|
+
* the filters a read applies, and reading the rows an index matched.
|
|
13
18
|
*/
|
|
14
19
|
import type { DatabaseSync, SQLInputValue } from "node:sqlite";
|
|
20
|
+
import type { WindowRef } from "@4fu/arbor-interfaces";
|
|
21
|
+
import { MIN_TRIGRAM_CHARS, likePattern, quoteFts, trigramMatch, wordMatch } from "@4fu/arbor-lexical";
|
|
22
|
+
import type { Ranked } from "@4fu/arbor-lexical";
|
|
23
|
+
export { MIN_TRIGRAM_CHARS, likePattern, quoteFts, trigramMatch, wordMatch };
|
|
15
24
|
export interface SearchFilters {
|
|
16
25
|
kinds?: readonly string[];
|
|
17
|
-
window?:
|
|
26
|
+
window?: WindowRef;
|
|
18
27
|
targets?: readonly string[];
|
|
19
28
|
external?: string;
|
|
20
29
|
afterSeq?: number;
|
|
21
30
|
beforeSeq?: number;
|
|
22
31
|
}
|
|
23
|
-
/** One matching row, with its raw relevance. Lower `rank` is better. */
|
|
24
|
-
export interface Ranked {
|
|
25
|
-
seq: number;
|
|
26
|
-
rank: number;
|
|
27
|
-
}
|
|
28
32
|
export interface FilterSql {
|
|
29
33
|
sql: string;
|
|
30
34
|
params: SQLInputValue[];
|
|
@@ -38,12 +42,3 @@ export declare function buildFilters(filters: SearchFilters): FilterSql;
|
|
|
38
42
|
export declare function ranked(db: DatabaseSync, table: string, match: string, filters: SearchFilters): Ranked[];
|
|
39
43
|
/** Every substring match, newest first. The scan has no relevance of its own. */
|
|
40
44
|
export declare function substring(db: DatabaseSync, pattern: string, filters: SearchFilters): Ranked[];
|
|
41
|
-
/** Escape a literal for `LIKE ... ESCAPE '\'`. */
|
|
42
|
-
export declare function escapeLike(value: string): string;
|
|
43
|
-
export declare function likePattern(query: string): string;
|
|
44
|
-
/** Quote one term for an FTS5 `MATCH`, so outside text is never syntax. */
|
|
45
|
-
export declare function quoteFts(value: string): string;
|
|
46
|
-
/** The trigram index takes one phrase. */
|
|
47
|
-
export declare function trigramMatch(query: string): string;
|
|
48
|
-
/** The word index takes the query's segments, all of them required. */
|
|
49
|
-
export declare function wordMatch(tokens: readonly string[]): string;
|
package/search.js
CHANGED
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Lexical search over the journal's events (docs/journal.md#search).
|
|
3
3
|
*
|
|
4
|
-
* Three indexes,
|
|
4
|
+
* Three indexes, and a fourth channel when a deployment installed one:
|
|
5
5
|
*
|
|
6
6
|
* - a word index, segmented and ranked with BM25;
|
|
7
7
|
* - a trigram index for exact substrings, which is what makes a Chinese
|
|
8
8
|
* substring findable;
|
|
9
9
|
* - a substring scan, for one- and two-character queries and for queries the
|
|
10
|
-
* trigram index cannot answer
|
|
10
|
+
* trigram index cannot answer;
|
|
11
|
+
* - a semantic channel, event text embedded and searched by meaning, when
|
|
12
|
+
* `@4fu/arbor-search-zvec` is installed.
|
|
11
13
|
*
|
|
12
|
-
*
|
|
14
|
+
* What the journal and the notes store share - how a literal is quoted and
|
|
15
|
+
* escaped, how a query becomes one index's match, and how the channels merge -
|
|
16
|
+
* lives in `@4fu/arbor-lexical`. What lives here is what is the journal's own:
|
|
17
|
+
* the filters a read applies, and reading the rows an index matched.
|
|
13
18
|
*/
|
|
19
|
+
import { MIN_TRIGRAM_CHARS, likePattern, quoteFts, trigramMatch, wordMatch } from "@4fu/arbor-lexical";
|
|
20
|
+
export { MIN_TRIGRAM_CHARS, likePattern, quoteFts, trigramMatch, wordMatch };
|
|
14
21
|
/**
|
|
15
22
|
* The filters a read applies, as SQL over `events e`. A target filter is an
|
|
16
23
|
* `EXISTS`, so one event with three targets still counts once.
|
|
@@ -48,42 +55,20 @@ export function buildFilters(filters) {
|
|
|
48
55
|
export function ranked(db, table, match, filters) {
|
|
49
56
|
const built = buildFilters(filters);
|
|
50
57
|
const rows = db
|
|
51
|
-
.prepare(`SELECT f.rowid AS
|
|
58
|
+
.prepare(`SELECT f.rowid AS rowid, bm25(${table}) AS rank
|
|
52
59
|
FROM ${table} f JOIN events e ON e.seq = f.rowid
|
|
53
60
|
WHERE ${table} MATCH ?${built.sql}
|
|
54
61
|
ORDER BY rank, f.rowid`)
|
|
55
62
|
.all(match, ...built.params);
|
|
56
|
-
return rows.map((row) => ({
|
|
63
|
+
return rows.map((row) => ({ rowid: Number(row.rowid), rank: Number(row.rank) }));
|
|
57
64
|
}
|
|
58
65
|
/** Every substring match, newest first. The scan has no relevance of its own. */
|
|
59
66
|
export function substring(db, pattern, filters) {
|
|
60
67
|
const built = buildFilters(filters);
|
|
61
68
|
const rows = db
|
|
62
|
-
.prepare(`SELECT e.seq AS
|
|
69
|
+
.prepare(`SELECT e.seq AS rowid, 1.0 AS rank FROM events e
|
|
63
70
|
WHERE e.body LIKE ? ESCAPE '\\'${built.sql}
|
|
64
71
|
ORDER BY e.seq DESC`)
|
|
65
72
|
.all(pattern, ...built.params);
|
|
66
|
-
return rows.map((row) => ({
|
|
67
|
-
}
|
|
68
|
-
/** Escape a literal for `LIKE ... ESCAPE '\'`. */
|
|
69
|
-
export function escapeLike(value) {
|
|
70
|
-
return value.replace(/[\\%_]/g, (character) => `\\${character}`);
|
|
71
|
-
}
|
|
72
|
-
export function likePattern(query) {
|
|
73
|
-
return `%${escapeLike(query)}%`;
|
|
74
|
-
}
|
|
75
|
-
/** Quote one term for an FTS5 `MATCH`, so outside text is never syntax. */
|
|
76
|
-
export function quoteFts(value) {
|
|
77
|
-
return `"${value.replace(/"/g, '""')}"`;
|
|
78
|
-
}
|
|
79
|
-
/** The trigram index takes one phrase. */
|
|
80
|
-
export function trigramMatch(query) {
|
|
81
|
-
return quoteFts(query);
|
|
82
|
-
}
|
|
83
|
-
/** The word index takes the query's segments, all of them required. */
|
|
84
|
-
export function wordMatch(tokens) {
|
|
85
|
-
return tokens
|
|
86
|
-
.filter((token) => token.length > 0)
|
|
87
|
-
.map(quoteFts)
|
|
88
|
-
.join(" ");
|
|
73
|
+
return rows.map((row) => ({ rowid: Number(row.rowid), rank: Number(row.rank) }));
|
|
89
74
|
}
|
package/semantic.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The semantic channel the journal uses when the caller names none
|
|
3
|
+
* (docs/journal.md#search).
|
|
4
|
+
*
|
|
5
|
+
* The spec calls for event text to be embedded and searched by meaning beside
|
|
6
|
+
* the lexical indexes, and the default is zvec when `@4fu/arbor-search-zvec` is
|
|
7
|
+
* installed: `resolveSemanticIndex` loads it, and the journal uses what it
|
|
8
|
+
* returns when the caller names no channel. The package is an optional peer
|
|
9
|
+
* dependency, so a deployment that leaves it out keeps working.
|
|
10
|
+
*
|
|
11
|
+
* What is left here is the absence that answers when the package is not there:
|
|
12
|
+
* no channel, and search is exactly lexical. That is also the answer when the
|
|
13
|
+
* package is installed but its vector store will not open - zvec's prebuilt
|
|
14
|
+
* binary does not load on this platform, or another host holds the collection -
|
|
15
|
+
* and when no embedding model is available, because the package says so by
|
|
16
|
+
* handing back nothing.
|
|
17
|
+
*/
|
|
18
|
+
import type { SemanticIndex } from "@4fu/arbor-interfaces";
|
|
19
|
+
/**
|
|
20
|
+
* The semantic channel a journal uses when the caller names none: the zvec one
|
|
21
|
+
* when the package is installed and its store opens, and none otherwise.
|
|
22
|
+
*
|
|
23
|
+
* `load` replaces how the zvec package is loaded. A host that resolves modules
|
|
24
|
+
* its own way passes its own; so does a test that has no zvec package to load. A
|
|
25
|
+
* load that fails falls back rather than stopping the journal, because the
|
|
26
|
+
* package is optional: it may be missing, or its prebuilt binary may not load on
|
|
27
|
+
* this platform.
|
|
28
|
+
*/
|
|
29
|
+
export declare function resolveSemanticIndex(path: string, load?: (path: string) => SemanticIndex | null): SemanticIndex | null;
|
package/semantic.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The semantic channel the journal uses when the caller names none
|
|
3
|
+
* (docs/journal.md#search).
|
|
4
|
+
*
|
|
5
|
+
* The spec calls for event text to be embedded and searched by meaning beside
|
|
6
|
+
* the lexical indexes, and the default is zvec when `@4fu/arbor-search-zvec` is
|
|
7
|
+
* installed: `resolveSemanticIndex` loads it, and the journal uses what it
|
|
8
|
+
* returns when the caller names no channel. The package is an optional peer
|
|
9
|
+
* dependency, so a deployment that leaves it out keeps working.
|
|
10
|
+
*
|
|
11
|
+
* What is left here is the absence that answers when the package is not there:
|
|
12
|
+
* no channel, and search is exactly lexical. That is also the answer when the
|
|
13
|
+
* package is installed but its vector store will not open - zvec's prebuilt
|
|
14
|
+
* binary does not load on this platform, or another host holds the collection -
|
|
15
|
+
* and when no embedding model is available, because the package says so by
|
|
16
|
+
* handing back nothing.
|
|
17
|
+
*/
|
|
18
|
+
import { createRequire } from "node:module";
|
|
19
|
+
/** The package that holds the zvec semantic index, when a deployment installed it. */
|
|
20
|
+
const ZVEC_PACKAGE = "@4fu/arbor-search-zvec";
|
|
21
|
+
/**
|
|
22
|
+
* Load the zvec semantic index over a store beside the journal's own, or `null`
|
|
23
|
+
* when it is not there: the package is not installed, or it holds no index.
|
|
24
|
+
*/
|
|
25
|
+
function loadZvec(path) {
|
|
26
|
+
const require = createRequire(import.meta.url);
|
|
27
|
+
const loaded = require(ZVEC_PACKAGE);
|
|
28
|
+
const create = loaded?.createZvecIndex;
|
|
29
|
+
if (typeof create !== "function")
|
|
30
|
+
return null;
|
|
31
|
+
const index = create({ path });
|
|
32
|
+
if (index === null || typeof index !== "object")
|
|
33
|
+
return null;
|
|
34
|
+
const shape = index;
|
|
35
|
+
if (typeof shape.add !== "function" || typeof shape.search !== "function")
|
|
36
|
+
return null;
|
|
37
|
+
return index;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The semantic channel a journal uses when the caller names none: the zvec one
|
|
41
|
+
* when the package is installed and its store opens, and none otherwise.
|
|
42
|
+
*
|
|
43
|
+
* `load` replaces how the zvec package is loaded. A host that resolves modules
|
|
44
|
+
* its own way passes its own; so does a test that has no zvec package to load. A
|
|
45
|
+
* load that fails falls back rather than stopping the journal, because the
|
|
46
|
+
* package is optional: it may be missing, or its prebuilt binary may not load on
|
|
47
|
+
* this platform.
|
|
48
|
+
*/
|
|
49
|
+
export function resolveSemanticIndex(path, load = loadZvec) {
|
|
50
|
+
try {
|
|
51
|
+
return load(path);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
package/sqlite-journal.d.ts
CHANGED
|
@@ -18,9 +18,9 @@
|
|
|
18
18
|
* the sequence an append assigns is atomic. The interface is asynchronous
|
|
19
19
|
* because a `Journal` may live behind a process boundary.
|
|
20
20
|
*/
|
|
21
|
-
import type { AroundOptions, BlobStore, Event, EventInput, EventPage, EventRef, Journal, Link, LinkInput, LinkPage, LinksOptions, ListOptions, Ref, SearchOptions, SearchPage, Segmenter, Target, TargetGap, TraceNode, TraceOptions, WindowRef } from "@4fu/arbor-interfaces";
|
|
22
|
-
|
|
23
|
-
export
|
|
21
|
+
import type { AroundOptions, BlobStore, Event, EventInput, EventPage, EventRef, Journal, Link, LinkInput, LinkPage, LinksOptions, ListOptions, Ref, SearchOptions, SearchPage, Segmenter, SemanticIndex, Target, TargetGap, TraceNode, TraceOptions, WindowRef } from "@4fu/arbor-interfaces";
|
|
22
|
+
import { MIN_TRIGRAM_CHARS } from "@4fu/arbor-lexical";
|
|
23
|
+
export { MIN_TRIGRAM_CHARS };
|
|
24
24
|
export interface SqliteJournalOptions {
|
|
25
25
|
/** A file path, or `:memory:`. */
|
|
26
26
|
path?: string;
|
|
@@ -28,6 +28,14 @@ export interface SqliteJournalOptions {
|
|
|
28
28
|
blobs?: BlobStore;
|
|
29
29
|
/** Word segmentation for the word index. */
|
|
30
30
|
segmenter?: Segmenter;
|
|
31
|
+
/**
|
|
32
|
+
* The semantic channel: event text embedded and searched by meaning
|
|
33
|
+
* (docs/journal.md#search). Omitted, the zvec channel is used when
|
|
34
|
+
* `@4fu/arbor-search-zvec` is installed and its store opens, and there is no
|
|
35
|
+
* channel otherwise. `null` forces lexical search even when the package is
|
|
36
|
+
* installed.
|
|
37
|
+
*/
|
|
38
|
+
semantic?: SemanticIndex | null;
|
|
31
39
|
/** The clock. Tests pass a fixed one. */
|
|
32
40
|
now?: () => number;
|
|
33
41
|
}
|
|
@@ -40,8 +48,11 @@ export declare class SqliteJournal implements Journal {
|
|
|
40
48
|
readonly path: string;
|
|
41
49
|
private readonly db;
|
|
42
50
|
private readonly segmenter;
|
|
51
|
+
private readonly semantic;
|
|
43
52
|
private readonly clock;
|
|
44
53
|
private readonly ownedBlobDirectory;
|
|
54
|
+
private semanticReady;
|
|
55
|
+
private semanticCaughtUp;
|
|
45
56
|
private closed;
|
|
46
57
|
constructor(options?: SqliteJournalOptions);
|
|
47
58
|
append(input: EventInput): Promise<Event>;
|
|
@@ -57,6 +68,35 @@ export declare class SqliteJournal implements Journal {
|
|
|
57
68
|
targetsBehind(window: WindowRef): Promise<TargetGap[]>;
|
|
58
69
|
redact(ref: EventRef, reason: string): Promise<Event>;
|
|
59
70
|
close(): Promise<void>;
|
|
71
|
+
/**
|
|
72
|
+
* Index the events the semantic channel does not hold yet, and say how many.
|
|
73
|
+
*
|
|
74
|
+
* A restart over an existing database, a channel that was not ready when the
|
|
75
|
+
* events arrived, and an append whose indexing failed all leave events to
|
|
76
|
+
* catch up on. A channel that is not ready yet holds what it was given and
|
|
77
|
+
* indexes that itself, so this answers 0 until it is.
|
|
78
|
+
*/
|
|
79
|
+
catchUpSemantic(): Promise<number>;
|
|
80
|
+
/** Hand one event's text to the semantic channel, when there is one. */
|
|
81
|
+
private addToSemantic;
|
|
82
|
+
/** Take one event's text back out of the semantic channel, when there is one. */
|
|
83
|
+
private removeFromSemantic;
|
|
84
|
+
/**
|
|
85
|
+
* The semantic channel's candidates, as ranks in the order it gave them.
|
|
86
|
+
*
|
|
87
|
+
* An absent channel, one that is not ready yet, and one that refuses all
|
|
88
|
+
* answer with nothing, and the search is exactly the lexical one. The
|
|
89
|
+
* journal's own filters decide which candidates count, so a channel that
|
|
90
|
+
* filters less than the journal does still answers the read it was asked for.
|
|
91
|
+
*/
|
|
92
|
+
private semanticHits;
|
|
93
|
+
/**
|
|
94
|
+
* The sequences among `refs` the journal's own filters allow, in the order
|
|
95
|
+
* given. A redacted event and one with no text are not among them, so a
|
|
96
|
+
* vector that outlived its content never reaches a page. The channel holds
|
|
97
|
+
* the ref a store gave it - an event ref here - so anything else is skipped.
|
|
98
|
+
*/
|
|
99
|
+
private allowedSeqs;
|
|
60
100
|
private ensureOpen;
|
|
61
101
|
private insertChildren;
|
|
62
102
|
private readEvent;
|
package/sqlite-journal.js
CHANGED
|
@@ -26,12 +26,19 @@ import { join } from "node:path";
|
|
|
26
26
|
import { arborError } from "@4fu/arbor-errors";
|
|
27
27
|
import { DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT } from "@4fu/arbor-interfaces";
|
|
28
28
|
import { FsBlobStore, blobIdOf } from "./blob-store.js";
|
|
29
|
-
import { indexText, resolveSegmenter } from "
|
|
29
|
+
import { indexText, mergeHits, resolveSegmenter, MIN_TRIGRAM_CHARS } from "@4fu/arbor-lexical";
|
|
30
|
+
import { resolveSemanticIndex } from "./semantic.js";
|
|
30
31
|
import { IMPLICIT_CITE_WEIGHT, TRIGRAM_TABLE, WORD_TABLE, createSchema } from "./schema.js";
|
|
31
32
|
import { buildFilters, likePattern, ranked, substring, trigramMatch, wordMatch } from "./search.js";
|
|
32
33
|
import { storedBlobRef, storedExternalRef, storedRef, storedWindowRef } from "./stored.js";
|
|
33
|
-
|
|
34
|
-
|
|
34
|
+
export { MIN_TRIGRAM_CHARS };
|
|
35
|
+
/**
|
|
36
|
+
* The fewest candidates the semantic channel is asked for. A page of one still
|
|
37
|
+
* fuses the channels over a handful of candidates, so a hit the word index
|
|
38
|
+
* ranked first is not lost to a semantic hit the channel would have ranked
|
|
39
|
+
* second.
|
|
40
|
+
*/
|
|
41
|
+
const MIN_SEMANTIC_CANDIDATES = 10;
|
|
35
42
|
/** How many related items one read appends (docs/prompting.md). */
|
|
36
43
|
const RELATED_LIMIT = 3;
|
|
37
44
|
const AROUND_BEFORE = 5;
|
|
@@ -125,8 +132,11 @@ export class SqliteJournal {
|
|
|
125
132
|
path;
|
|
126
133
|
db;
|
|
127
134
|
segmenter;
|
|
135
|
+
semantic;
|
|
128
136
|
clock;
|
|
129
137
|
ownedBlobDirectory;
|
|
138
|
+
semanticReady = false;
|
|
139
|
+
semanticCaughtUp = false;
|
|
130
140
|
closed = false;
|
|
131
141
|
constructor(options = {}) {
|
|
132
142
|
this.path = options.path ?? ":memory:";
|
|
@@ -134,14 +144,15 @@ export class SqliteJournal {
|
|
|
134
144
|
createSchema(this.db);
|
|
135
145
|
this.segmenter = options.segmenter ?? resolveSegmenter();
|
|
136
146
|
this.clock = options.now ?? (() => Date.now());
|
|
147
|
+
// An in-memory journal has no directory of its own; give it a temporary one
|
|
148
|
+
// and take it away on close. The blob store and the semantic channel's own
|
|
149
|
+
// store both live under it.
|
|
150
|
+
const root = this.path === ":memory:" ? join(tmpdir(), `arbor-journal-${randomUUID()}`) : null;
|
|
137
151
|
if (options.blobs !== undefined) {
|
|
138
152
|
this.blobs = options.blobs;
|
|
139
|
-
this.ownedBlobDirectory =
|
|
153
|
+
this.ownedBlobDirectory = root;
|
|
140
154
|
}
|
|
141
|
-
else if (
|
|
142
|
-
// An in-memory journal has no directory of its own; give it a temporary
|
|
143
|
-
// one and take it away on close.
|
|
144
|
-
const root = join(tmpdir(), `arbor-journal-${randomUUID()}`);
|
|
155
|
+
else if (root !== null) {
|
|
145
156
|
this.blobs = new FsBlobStore(join(root, "blobs"), new SqlBlobRegistry(this.db, join(root, "blobs")));
|
|
146
157
|
this.ownedBlobDirectory = root;
|
|
147
158
|
}
|
|
@@ -150,6 +161,10 @@ export class SqliteJournal {
|
|
|
150
161
|
this.blobs = new FsBlobStore(directory, new SqlBlobRegistry(this.db, directory));
|
|
151
162
|
this.ownedBlobDirectory = null;
|
|
152
163
|
}
|
|
164
|
+
this.semantic =
|
|
165
|
+
options.semantic === undefined
|
|
166
|
+
? resolveSemanticIndex(root === null ? `${this.path}.vectors` : join(root, "vectors"))
|
|
167
|
+
: options.semantic;
|
|
153
168
|
}
|
|
154
169
|
async append(input) {
|
|
155
170
|
this.ensureOpen();
|
|
@@ -174,6 +189,14 @@ export class SqliteJournal {
|
|
|
174
189
|
this.insertChildren(assigned, input, deriveLinks(input.links, input.refs, eventRefOf(assigned), time));
|
|
175
190
|
return assigned;
|
|
176
191
|
});
|
|
192
|
+
// The semantic channel is asynchronous, so it is indexed once the event is
|
|
193
|
+
// committed: an append never waits on a model, and a channel that is not
|
|
194
|
+
// ready yet holds the text until it is. With no channel there is nothing to
|
|
195
|
+
// wait for, and the append takes the path it always did.
|
|
196
|
+
const body = input.body;
|
|
197
|
+
if (this.semantic !== null && body !== null && body !== undefined && body !== "") {
|
|
198
|
+
await this.addToSemantic(seq, body, input.kind, input.window ?? null);
|
|
199
|
+
}
|
|
177
200
|
return this.readEvent(seq);
|
|
178
201
|
}
|
|
179
202
|
async get(ref) {
|
|
@@ -260,7 +283,11 @@ export class SqliteJournal {
|
|
|
260
283
|
// scan is its fallback.
|
|
261
284
|
substrings = substring(this.db, likePattern(trimmed), filters);
|
|
262
285
|
}
|
|
263
|
-
const
|
|
286
|
+
const semantic = this.semantic === null ? [] : await this.semanticHits(trimmed, limit, filters);
|
|
287
|
+
// Exact substring hits first, then word hits, then the hits only the
|
|
288
|
+
// semantic channel found: the vector channel adds what the lexical indexes
|
|
289
|
+
// missed and never moves what they found (docs/journal.md#search).
|
|
290
|
+
const merged = mergeHits({ channel: "substring", hits: substrings }, { channel: "word", hits: words }, { channel: "semantic", hits: semantic });
|
|
264
291
|
const page = merged.slice(offset, offset + limit);
|
|
265
292
|
const items = page.map((hit) => this.toSearchHit(hit));
|
|
266
293
|
return {
|
|
@@ -435,6 +462,10 @@ export class SqliteJournal {
|
|
|
435
462
|
});
|
|
436
463
|
for (const blob of redaction.erased)
|
|
437
464
|
await this.blobs.delete(storedBlobRef(blob));
|
|
465
|
+
// The vector the event left behind goes with its content: a redaction erases
|
|
466
|
+
// what the journal holds, and the semantic channel is one of those places.
|
|
467
|
+
if (this.semantic !== null)
|
|
468
|
+
await this.removeFromSemantic(seq);
|
|
438
469
|
return this.readEvent(redaction.by);
|
|
439
470
|
}
|
|
440
471
|
async close() {
|
|
@@ -442,11 +473,124 @@ export class SqliteJournal {
|
|
|
442
473
|
return;
|
|
443
474
|
this.closed = true;
|
|
444
475
|
this.db.close();
|
|
476
|
+
if (this.semantic !== null)
|
|
477
|
+
await this.semantic.close();
|
|
445
478
|
if (this.ownedBlobDirectory !== null) {
|
|
446
479
|
await rm(this.ownedBlobDirectory, { recursive: true, force: true });
|
|
447
480
|
}
|
|
448
481
|
}
|
|
482
|
+
/**
|
|
483
|
+
* Index the events the semantic channel does not hold yet, and say how many.
|
|
484
|
+
*
|
|
485
|
+
* A restart over an existing database, a channel that was not ready when the
|
|
486
|
+
* events arrived, and an append whose indexing failed all leave events to
|
|
487
|
+
* catch up on. A channel that is not ready yet holds what it was given and
|
|
488
|
+
* indexes that itself, so this answers 0 until it is.
|
|
489
|
+
*/
|
|
490
|
+
async catchUpSemantic() {
|
|
491
|
+
this.ensureOpen();
|
|
492
|
+
if (this.semantic === null || !(await this.semantic.ready()))
|
|
493
|
+
return 0;
|
|
494
|
+
const held = await this.semantic.held();
|
|
495
|
+
const rows = this.db
|
|
496
|
+
.prepare(`SELECT seq, kind, window, body FROM events
|
|
497
|
+
WHERE body IS NOT NULL AND redacted IS NULL ORDER BY seq`)
|
|
498
|
+
.all();
|
|
499
|
+
let indexed = 0;
|
|
500
|
+
for (const row of rows) {
|
|
501
|
+
const seq = toNumber(row.seq);
|
|
502
|
+
if (held.has(eventRefOf(seq)))
|
|
503
|
+
continue;
|
|
504
|
+
await this.addToSemantic(seq, row.body, row.kind, storedWindowRef(row.window));
|
|
505
|
+
indexed += 1;
|
|
506
|
+
}
|
|
507
|
+
return indexed;
|
|
508
|
+
}
|
|
449
509
|
// --- internals ---------------------------------------------------------
|
|
510
|
+
/** Hand one event's text to the semantic channel, when there is one. */
|
|
511
|
+
async addToSemantic(seq, body, kind, window) {
|
|
512
|
+
if (this.semantic === null)
|
|
513
|
+
return;
|
|
514
|
+
try {
|
|
515
|
+
await this.semantic.add(eventRefOf(seq), body, { kind, window });
|
|
516
|
+
}
|
|
517
|
+
catch {
|
|
518
|
+
// An optional channel that refuses leaves the event searchable lexically;
|
|
519
|
+
// a catch-up indexes it once the channel answers again.
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
/** Take one event's text back out of the semantic channel, when there is one. */
|
|
523
|
+
async removeFromSemantic(seq) {
|
|
524
|
+
if (this.semantic === null)
|
|
525
|
+
return;
|
|
526
|
+
try {
|
|
527
|
+
await this.semantic.remove(eventRefOf(seq));
|
|
528
|
+
}
|
|
529
|
+
catch {
|
|
530
|
+
// As with an append: the channel is optional, and the event is gone from
|
|
531
|
+
// every index the journal owns.
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* The semantic channel's candidates, as ranks in the order it gave them.
|
|
536
|
+
*
|
|
537
|
+
* An absent channel, one that is not ready yet, and one that refuses all
|
|
538
|
+
* answer with nothing, and the search is exactly the lexical one. The
|
|
539
|
+
* journal's own filters decide which candidates count, so a channel that
|
|
540
|
+
* filters less than the journal does still answers the read it was asked for.
|
|
541
|
+
*/
|
|
542
|
+
async semanticHits(query, limit, filters) {
|
|
543
|
+
if (this.semantic === null)
|
|
544
|
+
return [];
|
|
545
|
+
const ready = await this.semantic.ready();
|
|
546
|
+
if (ready !== this.semanticReady) {
|
|
547
|
+
// The channel just became usable, or stopped being: index what it missed
|
|
548
|
+
// while it was not.
|
|
549
|
+
this.semanticReady = ready;
|
|
550
|
+
this.semanticCaughtUp = false;
|
|
551
|
+
}
|
|
552
|
+
if (!this.semanticCaughtUp) {
|
|
553
|
+
this.semanticCaughtUp = true;
|
|
554
|
+
await this.catchUpSemantic();
|
|
555
|
+
}
|
|
556
|
+
if (!ready)
|
|
557
|
+
return [];
|
|
558
|
+
const asked = {
|
|
559
|
+
...(filters.kinds === undefined ? {} : { kinds: filters.kinds }),
|
|
560
|
+
...(filters.window === undefined ? {} : { window: filters.window }),
|
|
561
|
+
...(filters.afterSeq === undefined ? {} : { afterSeq: filters.afterSeq }),
|
|
562
|
+
...(filters.beforeSeq === undefined ? {} : { beforeSeq: filters.beforeSeq }),
|
|
563
|
+
};
|
|
564
|
+
const refs = await this.semantic.search(query, Math.max(limit, MIN_SEMANTIC_CANDIDATES), asked);
|
|
565
|
+
const allowed = this.allowedSeqs(refs, filters);
|
|
566
|
+
// The channel's order, as ranks the shared merge reads: the nearest hit
|
|
567
|
+
// carries the most negative rank, so it normalizes to the best score.
|
|
568
|
+
return allowed.map((rowid, index) => ({ rowid, rank: index - allowed.length }));
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* The sequences among `refs` the journal's own filters allow, in the order
|
|
572
|
+
* given. A redacted event and one with no text are not among them, so a
|
|
573
|
+
* vector that outlived its content never reaches a page. The channel holds
|
|
574
|
+
* the ref a store gave it - an event ref here - so anything else is skipped.
|
|
575
|
+
*/
|
|
576
|
+
allowedSeqs(refs, filters) {
|
|
577
|
+
const seqs = [];
|
|
578
|
+
for (const ref of refs) {
|
|
579
|
+
const seq = parseEventRef(ref);
|
|
580
|
+
if (seq !== null)
|
|
581
|
+
seqs.push(seq);
|
|
582
|
+
}
|
|
583
|
+
if (seqs.length === 0)
|
|
584
|
+
return [];
|
|
585
|
+
const built = buildFilters(filters);
|
|
586
|
+
const placeholders = seqs.map(() => "?").join(", ");
|
|
587
|
+
const rows = this.db
|
|
588
|
+
.prepare(`SELECT e.seq AS seq FROM events e
|
|
589
|
+
WHERE e.seq IN (${placeholders}) AND e.body IS NOT NULL AND e.redacted IS NULL${built.sql}`)
|
|
590
|
+
.all(...seqs, ...built.params);
|
|
591
|
+
const allowed = new Set(rows.map((row) => toNumber(row.seq)));
|
|
592
|
+
return seqs.filter((seq) => allowed.has(seq));
|
|
593
|
+
}
|
|
450
594
|
ensureOpen() {
|
|
451
595
|
if (this.closed) {
|
|
452
596
|
throw arborError({
|
|
@@ -567,7 +711,7 @@ export class SqliteJournal {
|
|
|
567
711
|
return { window: event.window, time: event.time, summary: summaryOf(event.body) };
|
|
568
712
|
}
|
|
569
713
|
toSearchHit(hit) {
|
|
570
|
-
const event = this.readEvent(hit.
|
|
714
|
+
const event = this.readEvent(hit.rowid);
|
|
571
715
|
if (event === null) {
|
|
572
716
|
throw arborError({
|
|
573
717
|
code: "internal",
|
|
@@ -582,7 +726,7 @@ export class SqliteJournal {
|
|
|
582
726
|
window: event.window,
|
|
583
727
|
kind: event.kind,
|
|
584
728
|
time: event.time,
|
|
585
|
-
match: hit.
|
|
729
|
+
match: hit.channel,
|
|
586
730
|
score: hit.score,
|
|
587
731
|
summary: summaryOf(event.body),
|
|
588
732
|
};
|
|
@@ -612,32 +756,6 @@ export function deriveLinks(declared, refs, from, time) {
|
|
|
612
756
|
}
|
|
613
757
|
return links;
|
|
614
758
|
}
|
|
615
|
-
/**
|
|
616
|
-
* Merge the two indexes: exact substring hits first, then word hits that are
|
|
617
|
-
* not already there, each ordered by its own relevance.
|
|
618
|
-
*/
|
|
619
|
-
function mergeHits(substrings, words) {
|
|
620
|
-
const seen = new Set();
|
|
621
|
-
const merged = [];
|
|
622
|
-
const push = (hits, match) => {
|
|
623
|
-
const best = Math.min(...hits.map((hit) => hit.rank));
|
|
624
|
-
for (const hit of hits) {
|
|
625
|
-
if (seen.has(hit.seq))
|
|
626
|
-
continue;
|
|
627
|
-
seen.add(hit.seq);
|
|
628
|
-
merged.push({ seq: hit.seq, match, score: normalize(hit.rank, best) });
|
|
629
|
-
}
|
|
630
|
-
};
|
|
631
|
-
push(substrings, "substring");
|
|
632
|
-
push(words, "word");
|
|
633
|
-
return merged;
|
|
634
|
-
}
|
|
635
|
-
/** Relevance in 0..1 within one index; higher is better. */
|
|
636
|
-
function normalize(rank, best) {
|
|
637
|
-
if (best === 0)
|
|
638
|
-
return 1;
|
|
639
|
-
return Math.max(0, Math.min(1, rank / best));
|
|
640
|
-
}
|
|
641
759
|
function filtersOf(options) {
|
|
642
760
|
return {
|
|
643
761
|
kinds: options.kinds?.map(String),
|
package/segmenter.d.ts
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Word segmentation for the journal's word index (docs/journal.md#search).
|
|
3
|
-
*
|
|
4
|
-
* The spec calls for jieba segmentation with BM25, and the default is jieba
|
|
5
|
-
* when `@4fu/arbor-segmenter-jieba` is installed: `resolveSegmenter` loads it, and
|
|
6
|
-
* the journal uses what it returns when the caller names no segmenter. The
|
|
7
|
-
* package is an optional peer dependency, so a deployment that leaves it out
|
|
8
|
-
* keeps working.
|
|
9
|
-
*
|
|
10
|
-
* What is left here is the approximation that answers when jieba is not there:
|
|
11
|
-
* Chinese, Japanese and Korean runs are indexed per character and per bigram,
|
|
12
|
-
* and every other script per word. Character and bigram indexing is what makes
|
|
13
|
-
* a two-character Chinese query find the phrase it appears in, without a
|
|
14
|
-
* dictionary.
|
|
15
|
-
*/
|
|
16
|
-
import type { Segmenter } from "@4fu/arbor-interfaces";
|
|
17
|
-
export declare const fallbackSegmenter: Segmenter;
|
|
18
|
-
/**
|
|
19
|
-
* The segmenter a journal uses when the caller names none: jieba when the
|
|
20
|
-
* package is installed, the per-character-and-bigram approximation otherwise.
|
|
21
|
-
*
|
|
22
|
-
* `load` replaces how the jieba package is loaded. A host that resolves modules
|
|
23
|
-
* its own way passes its own; so does a test that has no jieba package to load.
|
|
24
|
-
* A load that fails falls back rather than stopping the journal, because the
|
|
25
|
-
* package is optional: it may be missing, or its prebuilt binary may not load
|
|
26
|
-
* on this platform.
|
|
27
|
-
*/
|
|
28
|
-
export declare function resolveSegmenter(load?: () => Segmenter | null): Segmenter;
|
|
29
|
-
/** The text the word index stores: the segments, one per line. */
|
|
30
|
-
export declare function indexText(text: string, segmenter: Segmenter): string;
|
package/segmenter.js
DELETED
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Word segmentation for the journal's word index (docs/journal.md#search).
|
|
3
|
-
*
|
|
4
|
-
* The spec calls for jieba segmentation with BM25, and the default is jieba
|
|
5
|
-
* when `@4fu/arbor-segmenter-jieba` is installed: `resolveSegmenter` loads it, and
|
|
6
|
-
* the journal uses what it returns when the caller names no segmenter. The
|
|
7
|
-
* package is an optional peer dependency, so a deployment that leaves it out
|
|
8
|
-
* keeps working.
|
|
9
|
-
*
|
|
10
|
-
* What is left here is the approximation that answers when jieba is not there:
|
|
11
|
-
* Chinese, Japanese and Korean runs are indexed per character and per bigram,
|
|
12
|
-
* and every other script per word. Character and bigram indexing is what makes
|
|
13
|
-
* a two-character Chinese query find the phrase it appears in, without a
|
|
14
|
-
* dictionary.
|
|
15
|
-
*/
|
|
16
|
-
import { createRequire } from "node:module";
|
|
17
|
-
/** The package that holds the jieba segmenter, when a deployment installed it. */
|
|
18
|
-
const JIEBA_PACKAGE = "@4fu/arbor-segmenter-jieba";
|
|
19
|
-
/** CJK unified ideographs, extensions, compatibility, kana and hangul. */
|
|
20
|
-
function isCjk(codePoint) {
|
|
21
|
-
return ((codePoint >= 0x3400 && codePoint <= 0x4dbf) ||
|
|
22
|
-
(codePoint >= 0x4e00 && codePoint <= 0x9fff) ||
|
|
23
|
-
(codePoint >= 0xf900 && codePoint <= 0xfaff) ||
|
|
24
|
-
(codePoint >= 0x3040 && codePoint <= 0x30ff) ||
|
|
25
|
-
(codePoint >= 0xac00 && codePoint <= 0xd7af) ||
|
|
26
|
-
(codePoint >= 0x20000 && codePoint <= 0x2a6df) ||
|
|
27
|
-
(codePoint >= 0x2a700 && codePoint <= 0x2ebef));
|
|
28
|
-
}
|
|
29
|
-
/** Whitespace and punctuation separate words; everything else is part of one. */
|
|
30
|
-
const SEPARATOR = /[\s\p{P}\p{S}\p{C}\p{Z}]/u;
|
|
31
|
-
export const fallbackSegmenter = {
|
|
32
|
-
segment(text) {
|
|
33
|
-
const chars = Array.from(text);
|
|
34
|
-
const tokens = [];
|
|
35
|
-
let index = 0;
|
|
36
|
-
while (index < chars.length) {
|
|
37
|
-
const first = chars[index];
|
|
38
|
-
const codePoint = first.codePointAt(0);
|
|
39
|
-
if (isCjk(codePoint)) {
|
|
40
|
-
const run = [];
|
|
41
|
-
while (index < chars.length) {
|
|
42
|
-
const char = chars[index];
|
|
43
|
-
const point = char.codePointAt(0);
|
|
44
|
-
if (!isCjk(point))
|
|
45
|
-
break;
|
|
46
|
-
run.push(char);
|
|
47
|
-
index += 1;
|
|
48
|
-
}
|
|
49
|
-
tokens.push(...run);
|
|
50
|
-
for (let offset = 0; offset + 1 < run.length; offset += 1) {
|
|
51
|
-
tokens.push(run[offset] + run[offset + 1]);
|
|
52
|
-
}
|
|
53
|
-
continue;
|
|
54
|
-
}
|
|
55
|
-
if (SEPARATOR.test(first)) {
|
|
56
|
-
index += 1;
|
|
57
|
-
continue;
|
|
58
|
-
}
|
|
59
|
-
const run = [];
|
|
60
|
-
while (index < chars.length) {
|
|
61
|
-
const char = chars[index];
|
|
62
|
-
const point = char.codePointAt(0);
|
|
63
|
-
if (isCjk(point) || SEPARATOR.test(char))
|
|
64
|
-
break;
|
|
65
|
-
run.push(char);
|
|
66
|
-
index += 1;
|
|
67
|
-
}
|
|
68
|
-
tokens.push(run.join("").toLowerCase());
|
|
69
|
-
}
|
|
70
|
-
return tokens;
|
|
71
|
-
},
|
|
72
|
-
};
|
|
73
|
-
/**
|
|
74
|
-
* Load the jieba segmenter, or `null` when it is not there: the package is not
|
|
75
|
-
* installed, or it holds no segmenter.
|
|
76
|
-
*/
|
|
77
|
-
function loadJieba() {
|
|
78
|
-
const require = createRequire(import.meta.url);
|
|
79
|
-
const loaded = require(JIEBA_PACKAGE);
|
|
80
|
-
const candidate = loaded?.jiebaSegmenter;
|
|
81
|
-
if (candidate === null || typeof candidate !== "object")
|
|
82
|
-
return null;
|
|
83
|
-
const shape = candidate;
|
|
84
|
-
return typeof shape.segment === "function" ? candidate : null;
|
|
85
|
-
}
|
|
86
|
-
/**
|
|
87
|
-
* The segmenter a journal uses when the caller names none: jieba when the
|
|
88
|
-
* package is installed, the per-character-and-bigram approximation otherwise.
|
|
89
|
-
*
|
|
90
|
-
* `load` replaces how the jieba package is loaded. A host that resolves modules
|
|
91
|
-
* its own way passes its own; so does a test that has no jieba package to load.
|
|
92
|
-
* A load that fails falls back rather than stopping the journal, because the
|
|
93
|
-
* package is optional: it may be missing, or its prebuilt binary may not load
|
|
94
|
-
* on this platform.
|
|
95
|
-
*/
|
|
96
|
-
export function resolveSegmenter(load = loadJieba) {
|
|
97
|
-
try {
|
|
98
|
-
return load() ?? fallbackSegmenter;
|
|
99
|
-
}
|
|
100
|
-
catch {
|
|
101
|
-
return fallbackSegmenter;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
/** The text the word index stores: the segments, one per line. */
|
|
105
|
-
export function indexText(text, segmenter) {
|
|
106
|
-
return segmenter.segment(text).join("\n");
|
|
107
|
-
}
|