@4fu/arbor-journal-sqlite 2026.1106.0 → 2026.1107.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -6
- 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 +49 -3
- package/sqlite-journal.js +180 -43
- package/segmenter.d.ts +0 -30
- package/segmenter.js +0 -107
package/README.md
CHANGED
|
@@ -27,9 +27,11 @@ everything that takes judgment, such as git, merges and conflicts, to the agent.
|
|
|
27
27
|
guarantees what an agent cannot guarantee for itself - the record, isolation,
|
|
28
28
|
exactly-once effects, the checks, dispatch. Everything that takes judgment,
|
|
29
29
|
such as git, merges and conflicts, is the agent's.
|
|
30
|
-
- **A
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
- **A shared workspace with per-window checkouts.** The default workspace is
|
|
31
|
+
ovnfs: one shared folder every window reads, and a window opens its own
|
|
32
|
+
copy-on-write checkout of the project it needs, works there and merges back
|
|
33
|
+
with `apply`. It falls back to a plain directory per window when ovnfs is not
|
|
34
|
+
installed. It is not a sandbox: ovnfs isolates files, not processes.
|
|
33
35
|
- **A self-changing environment.** The model adds and changes skills, MCP
|
|
34
36
|
servers and resident services itself; a `reload` validates the change and
|
|
35
37
|
broadcasts it to every live window as a delta of names and descriptions.
|
|
@@ -54,7 +56,9 @@ npm install @4fu/arbor-core @4fu/arbor-journal-sqlite @4fu/arbor-notes-sqlite \
|
|
|
54
56
|
```
|
|
55
57
|
|
|
56
58
|
`@4fu/arbor-plugin-http` is optional; it is the example plugin below.
|
|
57
|
-
`@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.
|
|
58
62
|
|
|
59
63
|
## A minimal host
|
|
60
64
|
|
|
@@ -118,7 +122,10 @@ window gets, and how a continuation keeps working in it, are in
|
|
|
118
122
|
| [`@4fu/arbor-journal-sqlite`](packages/journal-sqlite) | the default `Journal` and `BlobStore` on SQLite |
|
|
119
123
|
| [`@4fu/arbor-notes-sqlite`](packages/notes-sqlite) | the default `NoteStore` on SQLite, beside the journal |
|
|
120
124
|
| [`@4fu/arbor-segmenter-jieba`](packages/segmenter-jieba) | the default `Segmenter`: jieba's dictionary segmentation for the word index, optional |
|
|
121
|
-
| [`@4fu/arbor-
|
|
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 |
|
|
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 |
|
|
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 |
|
|
122
129
|
| [`@4fu/arbor-environment-dir`](packages/environment-dir) | the default `Environment`: one `/env` directory, a revision per item, validation and deltas |
|
|
123
130
|
| [`@4fu/arbor-supervisor-process`](packages/supervisor-process) | the default `Supervisor`: one host process per service, restart with backoff, logs |
|
|
124
131
|
| [`@4fu/arbor-judge-jev`](packages/judge-jev) | the default `Judge`: Jev, TypeSafe AI's System One classifier, answering the dispatch decisions |
|
|
@@ -151,7 +158,7 @@ deployment can see what it would be taking on before it adopts one.
|
|
|
151
158
|
| [docs/coordination.md](docs/coordination.md) | the board, expectations, deliveries and broadcasts, staying current, effects |
|
|
152
159
|
| [docs/environment.md](docs/environment.md) | skills, MCP servers and services: using, changing, reload, built-in skills, deltas |
|
|
153
160
|
| [docs/memory.md](docs/memory.md) | notes, the active set, anchors, links |
|
|
154
|
-
| [docs/workspaces.md](docs/workspaces.md) | per-window
|
|
161
|
+
| [docs/workspaces.md](docs/workspaces.md) | the shared folder and per-window checkouts: isolation, continuity, what the agent does |
|
|
155
162
|
| [docs/tools.md](docs/tools.md) | direct tools and code mode modules |
|
|
156
163
|
| [docs/prompting.md](docs/prompting.md) | how every model-facing text is written: layers, notes, deliveries, errors, judge questions, terms |
|
|
157
164
|
| [docs/release.md](docs/release.md) | calendar versioning, the manual release steps, how a package is published |
|
|
@@ -168,6 +175,7 @@ tarball would contain ([docs/release.md](docs/release.md)).
|
|
|
168
175
|
| `packages/errors` | the error shape every refusal uses: the rule and the next action |
|
|
169
176
|
| `packages/journal-sqlite` | the default `Journal` and `BlobStore` on SQLite |
|
|
170
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 |
|
|
171
179
|
| `packages/notes-sqlite` | the default `NoteStore` on SQLite, beside the journal |
|
|
172
180
|
| `packages/judge-jev` | the default `Judge`: Jev, TypeSafe AI's System One classifier, answering the dispatch decisions |
|
|
173
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.0",
|
|
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.0",
|
|
45
|
+
"@4fu/arbor-lexical": "2026.1107.0"
|
|
45
46
|
},
|
|
46
47
|
"peerDependencies": {
|
|
47
|
-
"@4fu/arbor-
|
|
48
|
+
"@4fu/arbor-search-zvec": "2026.1107.0"
|
|
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;
|
|
@@ -64,7 +104,13 @@ export declare class SqliteJournal implements Journal {
|
|
|
64
104
|
/** One column of one table, for the child rows of an event. */
|
|
65
105
|
private strings;
|
|
66
106
|
private countEvents;
|
|
107
|
+
/**
|
|
108
|
+
* The newest event on a target that another window wrote after one window
|
|
109
|
+
* opened: what that window can still be behind on.
|
|
110
|
+
*/
|
|
67
111
|
private latestOn;
|
|
112
|
+
/** The sequence of one window's own `window_start`, or 0 before it opened. */
|
|
113
|
+
private openedAt;
|
|
68
114
|
/** Where a ref came from: the links that go out of it, strongest first. */
|
|
69
115
|
private sourcesOf;
|
|
70
116
|
private resolveRelated;
|
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 {
|
|
@@ -359,15 +386,21 @@ export class SqliteJournal {
|
|
|
359
386
|
WHERE e.window = ?
|
|
360
387
|
GROUP BY et.target`)
|
|
361
388
|
.all(window);
|
|
389
|
+
const openedAt = await this.openedAt(window);
|
|
362
390
|
const gaps = [];
|
|
363
391
|
for (const row of touched) {
|
|
364
|
-
|
|
392
|
+
// What a window is behind on is what arrived after it opened and what
|
|
393
|
+
// another window wrote: the task and the outside events it was opened
|
|
394
|
+
// for, and the window's own `window_start`, are not open work
|
|
395
|
+
// (docs/windows.md#ending-and-continuing).
|
|
396
|
+
const latest = this.latestOn(row.target, window, openedAt);
|
|
365
397
|
const watermark = (await this.watermark(window, row.target)) ?? 0;
|
|
366
398
|
if (latest <= watermark)
|
|
367
399
|
continue;
|
|
368
400
|
const behind = this.db
|
|
369
|
-
.prepare(
|
|
370
|
-
|
|
401
|
+
.prepare(`SELECT count(*) AS n FROM event_targets et JOIN events e ON e.seq = et.seq
|
|
402
|
+
WHERE et.target = ? AND et.seq > ? AND et.seq > ? AND e.window IS NOT ?`)
|
|
403
|
+
.get(row.target, watermark, openedAt, window);
|
|
371
404
|
gaps.push({
|
|
372
405
|
target: row.target,
|
|
373
406
|
latest,
|
|
@@ -429,6 +462,10 @@ export class SqliteJournal {
|
|
|
429
462
|
});
|
|
430
463
|
for (const blob of redaction.erased)
|
|
431
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);
|
|
432
469
|
return this.readEvent(redaction.by);
|
|
433
470
|
}
|
|
434
471
|
async close() {
|
|
@@ -436,11 +473,124 @@ export class SqliteJournal {
|
|
|
436
473
|
return;
|
|
437
474
|
this.closed = true;
|
|
438
475
|
this.db.close();
|
|
476
|
+
if (this.semantic !== null)
|
|
477
|
+
await this.semantic.close();
|
|
439
478
|
if (this.ownedBlobDirectory !== null) {
|
|
440
479
|
await rm(this.ownedBlobDirectory, { recursive: true, force: true });
|
|
441
480
|
}
|
|
442
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
|
+
}
|
|
443
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
|
+
}
|
|
444
594
|
ensureOpen() {
|
|
445
595
|
if (this.closed) {
|
|
446
596
|
throw arborError({
|
|
@@ -527,12 +677,25 @@ export class SqliteJournal {
|
|
|
527
677
|
.get(...built.params);
|
|
528
678
|
return toNumber(row.n);
|
|
529
679
|
}
|
|
530
|
-
|
|
680
|
+
/**
|
|
681
|
+
* The newest event on a target that another window wrote after one window
|
|
682
|
+
* opened: what that window can still be behind on.
|
|
683
|
+
*/
|
|
684
|
+
latestOn(target, window, after) {
|
|
531
685
|
const row = this.db
|
|
532
|
-
.prepare(
|
|
533
|
-
|
|
686
|
+
.prepare(`SELECT MAX(et.seq) AS latest FROM event_targets et
|
|
687
|
+
JOIN events e ON e.seq = et.seq
|
|
688
|
+
WHERE et.target = ? AND et.seq > ? AND e.window IS NOT ?`)
|
|
689
|
+
.get(target, after, window);
|
|
534
690
|
return row?.latest == null ? 0 : toNumber(row.latest);
|
|
535
691
|
}
|
|
692
|
+
/** The sequence of one window's own `window_start`, or 0 before it opened. */
|
|
693
|
+
async openedAt(window) {
|
|
694
|
+
const row = this.db
|
|
695
|
+
.prepare("SELECT MIN(seq) AS seq FROM events WHERE window = ? AND kind = 'window_start'")
|
|
696
|
+
.get(window);
|
|
697
|
+
return row?.seq == null ? 0 : toNumber(row.seq);
|
|
698
|
+
}
|
|
536
699
|
/** Where a ref came from: the links that go out of it, strongest first. */
|
|
537
700
|
sourcesOf(ref) {
|
|
538
701
|
const rows = this.db
|
|
@@ -548,7 +711,7 @@ export class SqliteJournal {
|
|
|
548
711
|
return { window: event.window, time: event.time, summary: summaryOf(event.body) };
|
|
549
712
|
}
|
|
550
713
|
toSearchHit(hit) {
|
|
551
|
-
const event = this.readEvent(hit.
|
|
714
|
+
const event = this.readEvent(hit.rowid);
|
|
552
715
|
if (event === null) {
|
|
553
716
|
throw arborError({
|
|
554
717
|
code: "internal",
|
|
@@ -563,7 +726,7 @@ export class SqliteJournal {
|
|
|
563
726
|
window: event.window,
|
|
564
727
|
kind: event.kind,
|
|
565
728
|
time: event.time,
|
|
566
|
-
match: hit.
|
|
729
|
+
match: hit.channel,
|
|
567
730
|
score: hit.score,
|
|
568
731
|
summary: summaryOf(event.body),
|
|
569
732
|
};
|
|
@@ -593,32 +756,6 @@ export function deriveLinks(declared, refs, from, time) {
|
|
|
593
756
|
}
|
|
594
757
|
return links;
|
|
595
758
|
}
|
|
596
|
-
/**
|
|
597
|
-
* Merge the two indexes: exact substring hits first, then word hits that are
|
|
598
|
-
* not already there, each ordered by its own relevance.
|
|
599
|
-
*/
|
|
600
|
-
function mergeHits(substrings, words) {
|
|
601
|
-
const seen = new Set();
|
|
602
|
-
const merged = [];
|
|
603
|
-
const push = (hits, match) => {
|
|
604
|
-
const best = Math.min(...hits.map((hit) => hit.rank));
|
|
605
|
-
for (const hit of hits) {
|
|
606
|
-
if (seen.has(hit.seq))
|
|
607
|
-
continue;
|
|
608
|
-
seen.add(hit.seq);
|
|
609
|
-
merged.push({ seq: hit.seq, match, score: normalize(hit.rank, best) });
|
|
610
|
-
}
|
|
611
|
-
};
|
|
612
|
-
push(substrings, "substring");
|
|
613
|
-
push(words, "word");
|
|
614
|
-
return merged;
|
|
615
|
-
}
|
|
616
|
-
/** Relevance in 0..1 within one index; higher is better. */
|
|
617
|
-
function normalize(rank, best) {
|
|
618
|
-
if (best === 0)
|
|
619
|
-
return 1;
|
|
620
|
-
return Math.max(0, Math.min(1, rank / best));
|
|
621
|
-
}
|
|
622
759
|
function filtersOf(options) {
|
|
623
760
|
return {
|
|
624
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
|
-
}
|