@4fu/arbor-lexical 0.0.0-stage → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 4fuu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,196 @@
1
- # Temporary Holding Version
1
+ # arbor
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ arbor is a framework for an agent without sessions: many context windows run
4
+ at once over one shared record, so work moves in parallel, replies come fast,
5
+ and every window can find everything that was ever done.
6
+
7
+ A deployment composes the pieces it wants and registers plugins for the domains
8
+ it serves. The framework guarantees what an agent cannot guarantee for itself -
9
+ the record, isolation, exactly-once effects, the checks, dispatch - and leaves
10
+ everything that takes judgment, such as git, merges and conflicts, to the agent.
11
+
12
+ ## Core ideas
13
+
14
+ - **No sessions to manage.** Work runs in disposable context windows. Any
15
+ number run at the same time; none of them owns anything, and a window that
16
+ ends loses nothing. The model looks up what it needs instead of carrying it.
17
+ - **One record, shared live.** Every input, tool call, result, send and note
18
+ revision goes into one journal and is visible to every window as
19
+ soon as it is written. Records never merge and are never lost.
20
+ - **Dispatch by a judge.** A judge (Jev by default, replaceable) decides which
21
+ live window takes a new input, or opens a new one. There is no router session
22
+ and no main line.
23
+ - **Current without locks.** Windows run in parallel. New events on a
24
+ conversation a window is in are steered into it as they arrive; only a note
25
+ write is checked against the revision the window read.
26
+ - **Mechanisms in the framework, policy in the agent.** The framework
27
+ guarantees what an agent cannot guarantee for itself - the record, isolation,
28
+ exactly-once effects, the checks, dispatch. Everything that takes judgment,
29
+ such as git, merges and conflicts, is the agent's.
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.
35
+ - **A self-changing environment.** The model adds and changes skills, MCP
36
+ servers and resident services itself; a `reload` validates the change and
37
+ broadcasts it to every live window as a delta of names and descriptions.
38
+ - **Minimal limits.** The framework sets no policy. Permissions, privacy
39
+ review, budgets and approvals belong to deployments built on it, through
40
+ hooks.
41
+ - **Direct tools plus code mode.** Decisions are direct tools; everything else
42
+ is a module called from `code_exec`. There is no CLI.
43
+ - **Every module is replaceable.** The agent backend (pi by default), the
44
+ journal, notes, workspaces, the judge, channels and the rest are interfaces
45
+ ([docs/architecture.md](docs/architecture.md#interfaces)).
46
+
47
+ ## Install
48
+
49
+ Node 24 or newer. Every package is published separately, so a deployment
50
+ installs the core and the defaults it wants:
51
+
52
+ ```sh
53
+ npm install @4fu/arbor-core @4fu/arbor-journal-sqlite @4fu/arbor-notes-sqlite \
54
+ @4fu/arbor-environment-dir @4fu/arbor-supervisor-process @4fu/arbor-agent-pi \
55
+ @4fu/arbor-workspace-dir
56
+ ```
57
+
58
+ `@4fu/arbor-plugin-http` is optional; it is the example plugin below.
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.
62
+
63
+ ## A minimal host
64
+
65
+ The smallest host needs a journal and a backend. Adding the defaults and one
66
+ plugin is what a deployment usually starts from:
67
+
68
+ ```js
69
+ import { mkdtemp } from "node:fs/promises";
70
+ import { tmpdir } from "node:os";
71
+ import { join } from "node:path";
72
+ import { Arbor } from "@4fu/arbor-core";
73
+ import { DirectoryEnvironment } from "@4fu/arbor-environment-dir";
74
+ import { SqliteJournal } from "@4fu/arbor-journal-sqlite";
75
+ import { SqliteNoteStore } from "@4fu/arbor-notes-sqlite";
76
+ import { ProcessSupervisor } from "@4fu/arbor-supervisor-process";
77
+ import { DirectoryWorkspace } from "@4fu/arbor-workspace-dir";
78
+ import { PiAgentBackend } from "@4fu/arbor-agent-pi";
79
+ import { HttpChannel, httpPlugin } from "@4fu/arbor-plugin-http";
80
+
81
+ const root = await mkdtemp(join(tmpdir(), "arbor-"));
82
+
83
+ // Every module below is a default implementation of an interface. Replace any
84
+ // one by passing your own; the host does not change.
85
+ const journal = new SqliteJournal({ path: join(root, "journal.sqlite") });
86
+ const notes = new SqliteNoteStore({ path: join(root, "notes.sqlite"), journal });
87
+ const supervisor = new ProcessSupervisor({ root: join(root, "services") });
88
+ const environment = new DirectoryEnvironment({ root: join(root, "env"), supervisor });
89
+ const workspace = new DirectoryWorkspace({ root: join(root, "workspaces") });
90
+
91
+ // A plugin adds; it never patches the core. This one is a chat channel: a
92
+ // webhook in, a window's sends out, and a page that drives the whole host.
93
+ let arbor;
94
+ const channel = new HttpChannel({ host: "127.0.0.1", port: 8787, board: () => arbor.boardLines() });
95
+ arbor = new Arbor({
96
+ journal,
97
+ notes,
98
+ backend: new PiAgentBackend(),
99
+ workspace,
100
+ environment,
101
+ supervisor,
102
+ plugins: [httpPlugin({ channel })],
103
+ });
104
+
105
+ await arbor.start();
106
+ await channel.listen();
107
+ ```
108
+
109
+ `arbor.open(task)` dispatches one task, and the journal is where everything that
110
+ happened can be read back ([docs/journal.md](docs/journal.md)). The workspace a
111
+ window gets, and how a continuation keeps working in it, are in
112
+ [docs/workspaces.md](docs/workspaces.md).
113
+
114
+ ## Packages
115
+
116
+ | Package | What it is |
117
+ | --- | --- |
118
+ | [`@4fu/arbor-interfaces`](packages/interfaces) | every replaceable interface, plus the core's own shapes. Types only. |
119
+ | [`@4fu/arbor-errors`](packages/errors) | the error shape every refusal uses: the rule and the next action |
120
+ | [`@4fu/arbor-prompt`](packages/prompt) | typed blocks, the one deterministic renderer and the replaceable text table |
121
+ | [`@4fu/arbor-tools`](packages/tools) | the entry points: direct tools, code mode modules, the host API |
122
+ | [`@4fu/arbor-journal-sqlite`](packages/journal-sqlite) | the default `Journal` and `BlobStore` on SQLite |
123
+ | [`@4fu/arbor-notes-sqlite`](packages/notes-sqlite) | the default `NoteStore` on SQLite, beside the journal |
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 |
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 |
129
+ | [`@4fu/arbor-environment-dir`](packages/environment-dir) | the default `Environment`: one `/env` directory, a revision per item, validation and deltas |
130
+ | [`@4fu/arbor-supervisor-process`](packages/supervisor-process) | the default `Supervisor`: one host process per service, restart with backoff, logs |
131
+ | [`@4fu/arbor-judge-jev`](packages/judge-jev) | the default `Judge`: Jev, TypeSafe AI's System One classifier, answering the dispatch decisions |
132
+ | [`@4fu/arbor-agent-pi`](packages/agent-pi) | the default `AgentBackend`: pi-agent-core in process |
133
+ | [`@4fu/arbor-plugin-http`](packages/plugin-http) | the HTTP test channel: a webhook in, sends out, a page that drives the whole host |
134
+ | [`@4fu/arbor-core`](packages/core) | the service layer: one implementation of every operation - the window lifecycle, dispatch, coordination, the board, notes access, workspaces, effects and schedules |
135
+
136
+ ### Defaults are examples
137
+
138
+ Every default above is a reference implementation of its interface, written to
139
+ be read and replaced. A real deployment is expected to swap out the ones that do
140
+ not fit: the SQLite journal and notes, the directory workspace, the process
141
+ supervisor, the environment directory, the HTTP test plugin and the Jev judge
142
+ are all examples of what an interface can be satisfied by, not commitments.
143
+ What a deployment keeps is its own choice, and the interfaces are the contract
144
+ ([docs/architecture.md](docs/architecture.md#interfaces)).
145
+
146
+ Each package's own documentation holds the limits of that default, so a
147
+ deployment can see what it would be taking on before it adopts one.
148
+
149
+ ## Documents
150
+
151
+ | Page | Contents |
152
+ | --- | --- |
153
+ | [docs/overview.md](docs/overview.md) | goals, concepts, principles, routes considered |
154
+ | [docs/architecture.md](docs/architecture.md) | layers, interfaces, stack, models, plugins, hooks, data placement |
155
+ | [docs/journal.md](docs/journal.md) | events and their order, targets, watermarks, external ids, refs, tracing, media, redaction |
156
+ | [docs/windows.md](docs/windows.md) | window lifecycle, prompt layout, reminders, continuation |
157
+ | [docs/dispatch.md](docs/dispatch.md) | task sources, judge decisions, insertion timing, backpressure, scheduled tasks |
158
+ | [docs/coordination.md](docs/coordination.md) | the board, expectations, deliveries and broadcasts, staying current, effects |
159
+ | [docs/environment.md](docs/environment.md) | skills, MCP servers and services: using, changing, reload, built-in skills, deltas |
160
+ | [docs/memory.md](docs/memory.md) | notes, the active set, anchors, links |
161
+ | [docs/workspaces.md](docs/workspaces.md) | the shared folder and per-window checkouts: isolation, continuity, what the agent does |
162
+ | [docs/tools.md](docs/tools.md) | direct tools and code mode modules |
163
+ | [docs/prompting.md](docs/prompting.md) | how every model-facing text is written: layers, notes, deliveries, errors, judge questions, terms |
164
+ | [docs/release.md](docs/release.md) | calendar versioning, the manual release steps, how a package is published |
165
+
166
+ ## Repository layout
167
+
168
+ `pnpm check` typechecks and tests every package on its TypeScript sources.
169
+ `pnpm build` emits the `dist` publishing needs, and `pnpm pack` shows what each
170
+ tarball would contain ([docs/release.md](docs/release.md)).
171
+
172
+ | Path | Layer |
173
+ | --- | --- |
174
+ | `packages/interfaces` | the interfaces layer: every replaceable interface, plus the core's own shapes. Types only. |
175
+ | `packages/errors` | the error shape every refusal uses: the rule and the next action |
176
+ | `packages/journal-sqlite` | the default `Journal` and `BlobStore` on SQLite |
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 |
179
+ | `packages/notes-sqlite` | the default `NoteStore` on SQLite, beside the journal |
180
+ | `packages/judge-jev` | the default `Judge`: Jev, TypeSafe AI's System One classifier, answering the dispatch decisions |
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 |
182
+ | `packages/environment-dir` | the default `Environment`: one `/env` directory, a revision per item, validation and deltas |
183
+ | `packages/supervisor-process` | the default `Supervisor`: one host process per service, restart with backoff, logs |
184
+ | `packages/plugin-http` | the HTTP test channel: a webhook in, sends out, a page that drives the whole host |
185
+ | `packages/prompt` | typed blocks, the one deterministic renderer and the replaceable text table |
186
+ | `packages/agent-pi` | the default `AgentBackend`: pi-agent-core in process |
187
+ | `packages/tools` | the entry points: direct tools, code mode modules, the host API |
188
+ | `packages/core` | the service layer: one implementation of every operation - the window lifecycle, dispatch, coordination, the board, notes access, workspaces, effects, schedules and the snapshot epochs |
189
+
190
+ `mise.toml` pins the toolchain - Node and pnpm - installed under
191
+ `~/.local/share/mise`, and `.agents/setup` prepares a machine from it. A machine
192
+ without mise is expected to carry Node 24 or newer and pnpm 12 already.
193
+
194
+ ## License
195
+
196
+ MIT, see [LICENSE](LICENSE).
package/index.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ /**
2
+ * @4fu/arbor-lexical - the lexical search the journal and notes share.
3
+ */
4
+ export { fallbackSegmenter, resolveSegmenter, indexText } from "./segmenter.ts";
5
+ export { MIN_TRIGRAM_CHARS, escapeLike, likePattern, quoteFts, trigramMatch, wordMatch, mergeHits, } from "./search.ts";
6
+ export type { ChannelHits, MergedHit, Ranked, SearchChannel } from "./search.ts";
package/index.js ADDED
@@ -0,0 +1,5 @@
1
+ /**
2
+ * @4fu/arbor-lexical - the lexical search the journal and notes share.
3
+ */
4
+ export { fallbackSegmenter, resolveSegmenter, indexText } from "./segmenter.js";
5
+ export { MIN_TRIGRAM_CHARS, escapeLike, likePattern, quoteFts, trigramMatch, wordMatch, mergeHits, } from "./search.js";
package/package.json CHANGED
@@ -1,6 +1,45 @@
1
1
  {
2
2
  "name": "@4fu/arbor-lexical",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "2026.1107.1",
4
+ "type": "module",
5
+ "description": "The lexical search the journal and notes share: word segmentation, the FTS5 helpers and the merge order.",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./index.d.ts",
9
+ "default": "./index.js"
10
+ }
11
+ },
12
+ "types": "./index.d.ts",
13
+ "main": "./index.js",
14
+ "files": [
15
+ "index.d.ts",
16
+ "index.js",
17
+ "search.d.ts",
18
+ "search.js",
19
+ "segmenter.d.ts",
20
+ "segmenter.js"
21
+ ],
22
+ "engines": {
23
+ "node": ">=24"
24
+ },
25
+ "license": "MIT",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/4fuu/arbor.git",
29
+ "directory": "packages/lexical"
30
+ },
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "dependencies": {
35
+ "@4fu/arbor-interfaces": "2026.1107.1"
36
+ },
37
+ "peerDependencies": {
38
+ "@4fu/arbor-segmenter-jieba": "2026.1107.1"
39
+ },
40
+ "peerDependenciesMeta": {
41
+ "@4fu/arbor-segmenter-jieba": {
42
+ "optional": true
43
+ }
44
+ }
45
+ }
package/search.d.ts ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The pieces of lexical search every store shares (docs/journal.md#search).
3
+ *
4
+ * All text search is lexical and built for Chinese and mixed text: a word index
5
+ * by jieba segmentation with BM25, a trigram index for exact substrings, and a
6
+ * fallback for one- or two-character queries. Results are merged, exact
7
+ * substring hits first, and then by relevance.
8
+ *
9
+ * What lives here is what that model needs and no store should own alone: how a
10
+ * literal is quoted for an FTS5 `MATCH` and escaped for `LIKE`, how a query
11
+ * becomes one index's match, and how the channels are merged. Each store keeps
12
+ * its own schema, its own filters and its own read of the rows a match names.
13
+ */
14
+ import type { MatchKind } from "@4fu/arbor-interfaces";
15
+ /** A query shorter than this cannot use the trigram index. */
16
+ export declare const MIN_TRIGRAM_CHARS = 3;
17
+ /**
18
+ * Which channel a match came from, and the `match` a hit carries. The two
19
+ * lexical channels are `substring` and `word`; `semantic` is the one a store adds
20
+ * when a deployment gave it an embedding channel (docs/journal.md#search).
21
+ */
22
+ export type SearchChannel = MatchKind;
23
+ /** One match from one index, with its raw relevance. Lower `rank` is better. */
24
+ export interface Ranked {
25
+ /** The row the index matched: an event's `seq`, a note's `id`. */
26
+ rowid: number;
27
+ rank: number;
28
+ }
29
+ /** One index's matches, named by the channel they are merged as. */
30
+ export interface ChannelHits {
31
+ channel: SearchChannel;
32
+ hits: readonly Ranked[];
33
+ }
34
+ /** One merged match, with the channel it came from and its relevance in 0..1. */
35
+ export interface MergedHit {
36
+ rowid: number;
37
+ channel: SearchChannel;
38
+ /** Relevance within the match's own channel; higher is better. */
39
+ score: number;
40
+ }
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;
50
+ /**
51
+ * Merge the channels the way lexical search does: the first channel's hits
52
+ * first, then the next channel's hits that are not already there. Each channel
53
+ * keeps the order it arrives in, which is its own relevance order - the query
54
+ * that fills a channel orders it - and a row two channels found stays where the
55
+ * first one put it. The channels are named at the call site in the order they
56
+ * merge, so a later one - a vector index, say - plugs in as one more argument
57
+ * and nothing here changes.
58
+ *
59
+ * A store with a semantic channel names it last, so the vector channel adds the
60
+ * hits no lexical index found and never moves one it did.
61
+ */
62
+ export declare function mergeHits(...channels: readonly ChannelHits[]): MergedHit[];
package/search.js ADDED
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The pieces of lexical search every store shares (docs/journal.md#search).
3
+ *
4
+ * All text search is lexical and built for Chinese and mixed text: a word index
5
+ * by jieba segmentation with BM25, a trigram index for exact substrings, and a
6
+ * fallback for one- or two-character queries. Results are merged, exact
7
+ * substring hits first, and then by relevance.
8
+ *
9
+ * What lives here is what that model needs and no store should own alone: how a
10
+ * literal is quoted for an FTS5 `MATCH` and escaped for `LIKE`, how a query
11
+ * becomes one index's match, and how the channels are merged. Each store keeps
12
+ * its own schema, its own filters and its own read of the rows a match names.
13
+ */
14
+ /** A query shorter than this cannot use the trigram index. */
15
+ export const MIN_TRIGRAM_CHARS = 3;
16
+ /** Escape a literal for `LIKE ... ESCAPE '\'`. */
17
+ export function escapeLike(value) {
18
+ return value.replace(/[\\%_]/g, (character) => `\\${character}`);
19
+ }
20
+ export function likePattern(query) {
21
+ return `%${escapeLike(query)}%`;
22
+ }
23
+ /** Quote one term for an FTS5 `MATCH`, so outside text is never syntax. */
24
+ export function quoteFts(value) {
25
+ return `"${value.replace(/"/g, '""')}"`;
26
+ }
27
+ /** The trigram index takes one phrase. */
28
+ export function trigramMatch(query) {
29
+ return quoteFts(query);
30
+ }
31
+ /** The word index takes the query's segments, all of them required. */
32
+ export function wordMatch(tokens) {
33
+ return tokens
34
+ .filter((token) => token.length > 0)
35
+ .map(quoteFts)
36
+ .join(" ");
37
+ }
38
+ /**
39
+ * Merge the channels the way lexical search does: the first channel's hits
40
+ * first, then the next channel's hits that are not already there. Each channel
41
+ * keeps the order it arrives in, which is its own relevance order - the query
42
+ * that fills a channel orders it - and a row two channels found stays where the
43
+ * first one put it. The channels are named at the call site in the order they
44
+ * merge, so a later one - a vector index, say - plugs in as one more argument
45
+ * and nothing here changes.
46
+ *
47
+ * A store with a semantic channel names it last, so the vector channel adds the
48
+ * hits no lexical index found and never moves one it did.
49
+ */
50
+ export function mergeHits(...channels) {
51
+ const seen = new Set();
52
+ const merged = [];
53
+ for (const { channel, hits } of channels) {
54
+ if (hits.length === 0)
55
+ continue;
56
+ const best = Math.min(...hits.map((hit) => hit.rank));
57
+ for (const hit of hits) {
58
+ if (seen.has(hit.rowid))
59
+ continue;
60
+ seen.add(hit.rowid);
61
+ merged.push({ rowid: hit.rowid, channel, score: normalize(hit.rank, best) });
62
+ }
63
+ }
64
+ return merged;
65
+ }
66
+ /** Relevance in 0..1 within one channel; higher is better. */
67
+ function normalize(rank, best) {
68
+ if (best === 0)
69
+ return 1;
70
+ return Math.max(0, Math.min(1, rank / best));
71
+ }
package/segmenter.d.ts ADDED
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Word segmentation for the 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
+ * a store that keeps a word index uses what it returns when the caller names no
7
+ * segmenter. The package is an optional peer dependency, so a deployment that
8
+ * leaves it out 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 store uses when the caller names none: jieba when the
20
+ * package is installed, the per-character-and-bigram approximation otherwise.
21
+ * `load` replaces how the jieba package is loaded. A host that resolves modules
22
+ * its own way passes its own; so does a test that has no jieba package to load.
23
+ * A load that fails falls back rather than stopping the store, because the
24
+ * package is optional: it may be missing, or its prebuilt binary may not load
25
+ * on this platform.
26
+ */
27
+ export declare function resolveSegmenter(load?: () => Segmenter | null): Segmenter;
28
+ /** The text the word index stores: the segments, one per line. */
29
+ export declare function indexText(text: string, segmenter: Segmenter): string;
package/segmenter.js ADDED
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Word segmentation for the 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
+ * a store that keeps a word index uses what it returns when the caller names no
7
+ * segmenter. The package is an optional peer dependency, so a deployment that
8
+ * leaves it out 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 store uses when the caller names none: jieba when the
88
+ * package is installed, the per-character-and-bigram approximation otherwise.
89
+ * `load` replaces how the jieba package is loaded. A host that resolves modules
90
+ * its own way passes its own; so does a test that has no jieba package to load.
91
+ * A load that fails falls back rather than stopping the store, because the
92
+ * package is optional: it may be missing, or its prebuilt binary may not load
93
+ * on this platform.
94
+ */
95
+ export function resolveSegmenter(load = loadJieba) {
96
+ try {
97
+ return load() ?? fallbackSegmenter;
98
+ }
99
+ catch {
100
+ return fallbackSegmenter;
101
+ }
102
+ }
103
+ /** The text the word index stores: the segments, one per line. */
104
+ export function indexText(text, segmenter) {
105
+ return segmenter.segment(text).join("\n");
106
+ }