@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 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 directory per window.** Each window gets its own directory, and a
31
- continuation keeps working in its predecessor's directory in place. It is not
32
- bound to a repository; the agent clones and pushes what it needs.
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 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.
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-workspace-dir`](packages/workspace-dir) | the default `Workspace`: one directory per window, with a continuation working in its predecessor's directory in place |
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 directories: isolation, continuity, what the agent does |
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 { 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.0",
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.0",
44
- "@4fu/arbor-interfaces": "2026.1106.0"
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.0"
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;
@@ -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 "./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 {
@@ -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
- const latest = this.latestOn(row.target);
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("SELECT count(*) AS n FROM event_targets WHERE target = ? AND seq > ?")
370
- .get(row.target, watermark);
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
- latestOn(target) {
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("SELECT MAX(seq) AS latest FROM event_targets WHERE target = ?")
533
- .get(target);
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.seq);
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.match,
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
- }