@4fu/arbor-journal-sqlite 2026.1106.1 → 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 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 journal.
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 { fallbackSegmenter, resolveSegmenter, indexText } from "./segmenter.ts";
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, escapeLike, likePattern, quoteFts, ranked, substring, trigramMatch, wordMatch, } from "./search.ts";
11
- export type { FilterSql, Ranked, SearchFilters } from "./search.ts";
12
- export { MIN_TRIGRAM_CHARS } from "./sqlite-journal.ts";
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 { fallbackSegmenter, resolveSegmenter, indexText } from "./segmenter.js";
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, escapeLike, likePattern, quoteFts, ranked, substring, trigramMatch, wordMatch, } from "./search.js";
9
- export { MIN_TRIGRAM_CHARS } from "./sqlite-journal.js";
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.1106.1",
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 lexical search on SQLite.",
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
- "segmenter.d.ts",
24
- "segmenter.js",
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.1106.1",
44
- "@4fu/arbor-interfaces": "2026.1106.1"
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-segmenter-jieba": "2026.1106.1"
48
+ "@4fu/arbor-search-zvec": "2026.1107.0"
48
49
  },
49
50
  "peerDependenciesMeta": {
50
- "@4fu/arbor-segmenter-jieba": {
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, merged:
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
- * Exact substring hits come first, then word hits that are not already there.
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?: string;
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, merged:
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
- * Exact substring hits come first, then word hits that are not already there.
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 seq, bm25(${table}) AS rank
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) => ({ seq: Number(row.seq), rank: Number(row.rank) }));
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 seq, 1.0 AS rank FROM events e
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) => ({ seq: Number(row.seq), rank: Number(row.rank) }));
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
+ }
@@ -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
- /** A query shorter than this cannot use the trigram index. */
23
- export declare const MIN_TRIGRAM_CHARS = 3;
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 "./segmenter.js";
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
- /** A query shorter than this cannot use the trigram index. */
34
- export const MIN_TRIGRAM_CHARS = 3;
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 = null;
153
+ this.ownedBlobDirectory = root;
140
154
  }
141
- else if (this.path === ":memory:") {
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 merged = mergeHits(substrings, words);
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.seq);
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.match,
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
- }