@lapis-notes/search 0.1.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/LICENSE.md +8 -0
  3. package/README.md +20 -0
  4. package/dist/agent-tools.d.ts +1 -0
  5. package/dist/agent-tools.js +1 -0
  6. package/dist/built-in-search-document-providers.d.ts +5 -0
  7. package/dist/built-in-search-document-providers.js +30 -0
  8. package/dist/highlighted-text.svelte +41 -0
  9. package/dist/highlighted-text.svelte.d.ts +11 -0
  10. package/dist/index.d.ts +11 -0
  11. package/dist/index.js +9 -0
  12. package/dist/notes-search-command.d.ts +2 -0
  13. package/dist/notes-search-command.js +8 -0
  14. package/dist/notes-search-tool.d.ts +8 -0
  15. package/dist/notes-search-tool.js +84 -0
  16. package/dist/public-components.d.ts +2 -0
  17. package/dist/public-components.js +2 -0
  18. package/dist/search-manager.d.ts +92 -0
  19. package/dist/search-manager.js +771 -0
  20. package/dist/search-match-context.d.ts +17 -0
  21. package/dist/search-match-context.js +45 -0
  22. package/dist/search-panel.css +460 -0
  23. package/dist/search-panel.svelte +796 -0
  24. package/dist/search-panel.svelte.d.ts +11 -0
  25. package/dist/search-plugin.d.ts +22 -0
  26. package/dist/search-plugin.js +174 -0
  27. package/dist/search-result-model.d.ts +29 -0
  28. package/dist/search-result-model.js +50 -0
  29. package/dist/search-settings-tab.d.ts +12 -0
  30. package/dist/search-settings-tab.js +139 -0
  31. package/dist/search-settings.d.ts +54 -0
  32. package/dist/search-settings.js +107 -0
  33. package/dist/search-sort.d.ts +8 -0
  34. package/dist/search-sort.js +36 -0
  35. package/dist/search-startup.d.ts +3 -0
  36. package/dist/search-startup.js +4 -0
  37. package/dist/search-tool-result.css +70 -0
  38. package/dist/search-tool-result.d.ts +16 -0
  39. package/dist/search-tool-result.js +111 -0
  40. package/dist/search-tool-result.svelte +55 -0
  41. package/dist/search-tool-result.svelte.d.ts +6 -0
  42. package/dist/search-view-type.d.ts +1 -0
  43. package/dist/search-view-type.js +1 -0
  44. package/dist/search-view.d.ts +14 -0
  45. package/dist/search-view.js +48 -0
  46. package/dist/styles.css +2 -0
  47. package/manifest.json +28 -0
  48. package/package.json +80 -0
  49. package/styles.css +1 -0
@@ -0,0 +1,11 @@
1
+ import "./search-panel.css";
2
+ import { type App } from "@lapis-notes/api";
3
+ type $$ComponentProps = {
4
+ app: App;
5
+ initialQuery?: string;
6
+ };
7
+ declare const SearchPanel: import("svelte").Component<$$ComponentProps, {
8
+ setSearchQuery: (next: string) => void;
9
+ }, "">;
10
+ type SearchPanel = ReturnType<typeof SearchPanel>;
11
+ export default SearchPanel;
@@ -0,0 +1,22 @@
1
+ import { type App, Plugin, type PluginManifest } from "@lapis-notes/api";
2
+ import { SearchManager, type SearchRuntimeStatus } from "./search-manager";
3
+ import { type SearchPluginSettings, type SearchPluginSettingsPatch } from "./search-settings";
4
+ export declare class SearchPlugin extends Plugin {
5
+ private settings;
6
+ private startupRefreshStarted;
7
+ private startupRefreshPromise;
8
+ private providerReconcileReason;
9
+ private providerReconcilePromise;
10
+ readonly searchManager: SearchManager;
11
+ constructor(app: App, pluginManifest?: PluginManifest);
12
+ getSettings(): SearchPluginSettings;
13
+ updateSettings(patch: SearchPluginSettingsPatch): Promise<void>;
14
+ getSearchStatus(): Promise<SearchRuntimeStatus>;
15
+ rebuildSemanticSearch(): Promise<SearchRuntimeStatus>;
16
+ onload(): Promise<void>;
17
+ refreshIndex(reason?: string): Promise<SearchRuntimeStatus>;
18
+ onunload(): Promise<void>;
19
+ private startupRefresh;
20
+ private scheduleProviderReconciliation;
21
+ private openSearchInLeftSidebar;
22
+ }
@@ -0,0 +1,174 @@
1
+ import { Notice, Plugin, TextFileView, } from "@lapis-notes/api";
2
+ import { SearchManager } from "./search-manager";
3
+ import { CANVAS_SEARCH_DOCUMENT_PROVIDER, MARKDOWN_SEARCH_DOCUMENT_PROVIDER, } from "./built-in-search-document-providers";
4
+ import { mergeSearchSettings, patchSearchSettings, resolveSearchEmbeddingProviderConfig, } from "./search-settings";
5
+ import { SearchSettingsTab } from "./search-settings-tab";
6
+ import { SearchView, SearchViewType } from "./search-view";
7
+ import { createNotesSearchSlashCommand } from "./notes-search-command";
8
+ import { createNotesSearchTool } from "./notes-search-tool";
9
+ import SearchToolResult from "./search-tool-result.svelte";
10
+ import { reconcileSearchAfterMetadata } from "./search-startup";
11
+ import manifestSpec from "../../manifest.json";
12
+ export class SearchPlugin extends Plugin {
13
+ settings;
14
+ startupRefreshStarted = false;
15
+ startupRefreshPromise = null;
16
+ providerReconcileReason = null;
17
+ providerReconcilePromise = null;
18
+ searchManager;
19
+ constructor(app, pluginManifest = manifestSpec) {
20
+ super(app, pluginManifest);
21
+ this.settings = mergeSearchSettings(null);
22
+ this.searchManager = new SearchManager(app, () => this.settings);
23
+ }
24
+ getSettings() {
25
+ return {
26
+ chunking: { ...this.settings.chunking },
27
+ query: { ...this.settings.query },
28
+ embeddings: { ...this.settings.embeddings },
29
+ view: {
30
+ ...this.settings.view,
31
+ recentSearches: [...this.settings.view.recentSearches],
32
+ },
33
+ };
34
+ }
35
+ async updateSettings(patch) {
36
+ const previousProvider = resolveSearchEmbeddingProviderConfig(this.settings);
37
+ this.settings = patchSearchSettings(this.settings, patch);
38
+ await this.saveData(this.settings);
39
+ const nextProvider = resolveSearchEmbeddingProviderConfig(this.settings);
40
+ if (JSON.stringify(previousProvider) !== JSON.stringify(nextProvider)) {
41
+ await this.app.appDatabase.configureSearchEmbeddingProvider(nextProvider);
42
+ }
43
+ }
44
+ getSearchStatus() {
45
+ return this.searchManager.getStatus();
46
+ }
47
+ rebuildSemanticSearch() {
48
+ return this.searchManager.refreshFromVault("manual-semantic-rebuild");
49
+ }
50
+ async onload() {
51
+ this.settings = mergeSearchSettings(await this.loadData());
52
+ await this.app.appDatabase.configureSearchEmbeddingProvider(resolveSearchEmbeddingProviderConfig(this.settings));
53
+ this.registerEvent(this.app.searchDocumentProviders.on("changed", ({ reason }) => {
54
+ if (this.state !== "enabled")
55
+ return;
56
+ if (!this.app.metadataCache.initialized ||
57
+ !this.startupRefreshStarted) {
58
+ return;
59
+ }
60
+ this.scheduleProviderReconciliation(`provider-${reason}`);
61
+ }));
62
+ this.registerSearchDocumentProvider("markdown", MARKDOWN_SEARCH_DOCUMENT_PROVIDER);
63
+ this.registerSearchDocumentProvider("canvas", CANVAS_SEARCH_DOCUMENT_PROVIDER);
64
+ this.registerAgentTool(createNotesSearchTool(this.searchManager));
65
+ this.registerAgentSlashCommand(createNotesSearchSlashCommand());
66
+ this.registerAgentResultView({
67
+ tool: "notes_search",
68
+ component: SearchToolResult,
69
+ });
70
+ this.addSettingTab(new SearchSettingsTab(this.app, this));
71
+ this.register(this.searchManager.trackChanges());
72
+ this.registerSidebarView(SearchViewType, (leaf) => new SearchView(leaf), { side: "left", title: "Search", icon: "search" }, {
73
+ kind: "command",
74
+ command: {
75
+ id: "open-search",
76
+ name: "Open Search",
77
+ callback: (query) => void this.openSearchInLeftSidebar(query),
78
+ },
79
+ });
80
+ this.registerEvent(this.app.metadataCache.on("loaded", () => {
81
+ void this.startupRefresh();
82
+ }));
83
+ if (this.app.metadataCache.initialized) {
84
+ void this.startupRefresh();
85
+ }
86
+ this.addCommand({
87
+ id: "rebuild-semantic-search",
88
+ name: "Rebuild semantic search embeddings",
89
+ callback: async () => {
90
+ const status = await this.rebuildSemanticSearch();
91
+ new Notice(`Semantic search refreshed (${status.readyChunkCount}/${status.chunkCount} ready)`);
92
+ },
93
+ });
94
+ this.addCommand({
95
+ id: "search-selected-text",
96
+ name: "Search selected text",
97
+ callback: () => {
98
+ const view = this.app.workspace.activeLeaf?.view;
99
+ const query = view instanceof TextFileView ? view.editor.getSelection().trim() : "";
100
+ void this.openSearchInLeftSidebar(query);
101
+ },
102
+ });
103
+ this.addCommand({
104
+ id: "rebuild-search-index",
105
+ name: "Rebuild search index",
106
+ callback: async () => {
107
+ await this.app.appDatabase.rebuildSearchIndex();
108
+ await this.refreshIndex("rebuild");
109
+ new Notice("Search index rebuilt");
110
+ },
111
+ });
112
+ this.addCommand({
113
+ id: "refresh-search-index",
114
+ name: "Refresh search index",
115
+ callback: () => void this.refreshIndex("command"),
116
+ });
117
+ }
118
+ refreshIndex(reason = "manual") {
119
+ return this.searchManager.refreshFromVault(reason);
120
+ }
121
+ async onunload() {
122
+ await this.searchManager.dispose();
123
+ }
124
+ startupRefresh() {
125
+ if (this.startupRefreshPromise)
126
+ return this.startupRefreshPromise;
127
+ if (this.startupRefreshStarted)
128
+ return Promise.resolve();
129
+ this.startupRefreshStarted = true;
130
+ this.startupRefreshPromise = reconcileSearchAfterMetadata(this.app, this.searchManager)
131
+ .then(() => undefined)
132
+ .catch((error) => {
133
+ this.startupRefreshStarted = false;
134
+ new Notice(`Search index refresh failed: ${error instanceof Error ? error.message : String(error)}`);
135
+ })
136
+ .finally(() => {
137
+ if (!this.startupRefreshStarted)
138
+ this.startupRefreshPromise = null;
139
+ });
140
+ return this.startupRefreshPromise;
141
+ }
142
+ scheduleProviderReconciliation(reason) {
143
+ this.providerReconcileReason = reason;
144
+ if (this.providerReconcilePromise)
145
+ return;
146
+ this.providerReconcilePromise = (async () => {
147
+ await this.startupRefresh();
148
+ while (this.providerReconcileReason) {
149
+ this.providerReconcileReason = null;
150
+ await this.searchManager.reconcileStartup();
151
+ }
152
+ })()
153
+ .catch((error) => {
154
+ new Notice(`Search provider refresh failed: ${error instanceof Error ? error.message : String(error)}`);
155
+ })
156
+ .finally(() => {
157
+ this.providerReconcilePromise = null;
158
+ if (this.providerReconcileReason) {
159
+ this.scheduleProviderReconciliation(this.providerReconcileReason);
160
+ }
161
+ });
162
+ }
163
+ async openSearchInLeftSidebar(query = "") {
164
+ const existing = this.app.workspace.getLeavesOfType(SearchViewType)[0];
165
+ const target = existing ?? this.app.workspace.ensureSideLeaf(SearchViewType, "left");
166
+ await target.setViewState({ type: SearchViewType, state: { query } });
167
+ this.app.workspace.activateLeaf(target, {
168
+ focusRootHost: false,
169
+ source: "api",
170
+ operation: "open-search",
171
+ });
172
+ await this.app.workspace.revealLeaf(target);
173
+ }
174
+ }
@@ -0,0 +1,29 @@
1
+ import type { App, EditorPosition, TFile } from "@lapis-notes/api";
2
+ import type { SearchQueryHit } from "./search-manager";
3
+ export type SearchRange = {
4
+ start: number;
5
+ end: number;
6
+ };
7
+ export type SearchMatch = {
8
+ id: string;
9
+ key: string;
10
+ text: string;
11
+ ranges: SearchRange[];
12
+ pos?: EditorPosition;
13
+ context?: {
14
+ start: number;
15
+ end: number;
16
+ ranges: SearchRange[];
17
+ sourceLength: number;
18
+ };
19
+ };
20
+ export type SearchResult = {
21
+ file: TFile;
22
+ title: {
23
+ text: string;
24
+ ranges: SearchRange[];
25
+ } | null;
26
+ matches: SearchMatch[];
27
+ hit: SearchQueryHit;
28
+ };
29
+ export declare function searchResultFromHit(app: App, hit: SearchQueryHit): SearchResult | null;
@@ -0,0 +1,50 @@
1
+ function offsetPosition(content, offset) {
2
+ const before = content.slice(0, Math.max(0, offset));
3
+ const lines = before.split("\n");
4
+ return {
5
+ line: lines.length - 1,
6
+ ch: lines.at(-1)?.length ?? 0,
7
+ };
8
+ }
9
+ export function searchResultFromHit(app, hit) {
10
+ const file = app.vault.getFileByPath(hit.id);
11
+ if (!file)
12
+ return null;
13
+ const title = hit.snippets.find((snippet) => snippet.field === "name");
14
+ const matches = hit.snippets
15
+ .filter((snippet) => snippet.field !== "name")
16
+ .map((snippet, index) => ({
17
+ id: `${snippet.field}:${snippet.offset}:${index}`,
18
+ key: snippet.field === "tags" ? "tag" : snippet.field,
19
+ text: snippet.text,
20
+ ranges: snippet.ranges,
21
+ ...(snippet.field === "content" && snippet.ranges.length
22
+ ? {
23
+ pos: offsetPosition(hit.document.content, snippet.offset + snippet.ranges[0].start),
24
+ context: {
25
+ start: snippet.offset,
26
+ end: snippet.offset + snippet.text.length,
27
+ ranges: snippet.ranges.map((range) => ({
28
+ start: snippet.offset + range.start,
29
+ end: snippet.offset + range.end,
30
+ })),
31
+ sourceLength: hit.document.content.length,
32
+ },
33
+ }
34
+ : {}),
35
+ }));
36
+ if (matches.length === 0) {
37
+ matches.push({
38
+ id: "name:0:fallback",
39
+ key: "name",
40
+ text: title?.text || file.name,
41
+ ranges: title?.ranges ?? [],
42
+ });
43
+ }
44
+ return {
45
+ file,
46
+ title: title ? { text: title.text, ranges: title.ranges } : null,
47
+ matches,
48
+ hit,
49
+ };
50
+ }
@@ -0,0 +1,12 @@
1
+ import { PluginSettingTab, type App } from "@lapis-notes/api";
2
+ import type { SearchPlugin } from "./search-plugin";
3
+ export declare class SearchSettingsTab extends PluginSettingTab {
4
+ private readonly searchPlugin;
5
+ private rebuilding;
6
+ constructor(app: App, searchPlugin: SearchPlugin);
7
+ display(): void;
8
+ private addSlider;
9
+ private update;
10
+ private configureRebuildButton;
11
+ private rebuild;
12
+ }
@@ -0,0 +1,139 @@
1
+ import { Notice, PluginSettingTab, Setting, } from "@lapis-notes/api";
2
+ import { CUSTOM_SEARCH_EMBEDDING_MODEL_OPTION, SEARCH_EMBEDDING_MODEL_OPTIONS, getSearchEmbeddingCustomOptionLabel, getSearchEmbeddingModelOptionValue, isPresetSearchEmbeddingModelId, } from "./search-settings";
3
+ export class SearchSettingsTab extends PluginSettingTab {
4
+ searchPlugin;
5
+ rebuilding = false;
6
+ constructor(app, searchPlugin) {
7
+ super(app, searchPlugin);
8
+ this.searchPlugin = searchPlugin;
9
+ }
10
+ display() {
11
+ const settings = this.searchPlugin.getSettings();
12
+ const modelOption = getSearchEmbeddingModelOptionValue(settings.embeddings.modelId);
13
+ this.containerEl.empty();
14
+ new Setting(this.containerEl)
15
+ .setName("Semantic search provider")
16
+ .setDesc("Semantic search is disabled until you opt in to the local Transformers.js provider.")
17
+ .addDropdown((dropdown) => {
18
+ dropdown
19
+ .addOption("disabled", "Disabled")
20
+ .addOption("transformers-js", "Transformers.js")
21
+ .setValue(settings.embeddings.provider)
22
+ .onChange((provider) => {
23
+ void this.update({
24
+ embeddings: {
25
+ provider: provider,
26
+ },
27
+ });
28
+ });
29
+ });
30
+ new Setting(this.containerEl)
31
+ .setName("Embedding model")
32
+ .setDesc("Model used for local document and query vectors.")
33
+ .addDropdown((dropdown) => {
34
+ for (const modelId of SEARCH_EMBEDDING_MODEL_OPTIONS) {
35
+ dropdown.addOption(modelId, modelId);
36
+ }
37
+ dropdown
38
+ .addOption(CUSTOM_SEARCH_EMBEDDING_MODEL_OPTION, getSearchEmbeddingCustomOptionLabel(settings.embeddings.modelId))
39
+ .setValue(modelOption)
40
+ .onChange((modelId) => {
41
+ if (Array.isArray(modelId))
42
+ return;
43
+ if (modelId === CUSTOM_SEARCH_EMBEDDING_MODEL_OPTION) {
44
+ this.display();
45
+ return;
46
+ }
47
+ void this.update({ embeddings: { modelId } });
48
+ });
49
+ });
50
+ if (modelOption === CUSTOM_SEARCH_EMBEDDING_MODEL_OPTION) {
51
+ new Setting(this.containerEl)
52
+ .setName("Custom embedding model")
53
+ .setDesc("Hugging Face model id used by Transformers.js.")
54
+ .addText((text) => {
55
+ text
56
+ .setPlaceholder("org/model-name")
57
+ .setValue(isPresetSearchEmbeddingModelId(settings.embeddings.modelId)
58
+ ? ""
59
+ : settings.embeddings.modelId)
60
+ .onChange((modelId) => {
61
+ void this.update({ embeddings: { modelId } }, false);
62
+ });
63
+ });
64
+ }
65
+ new Setting(this.containerEl)
66
+ .setName("Allow remote model downloads")
67
+ .setDesc("Allow the selected model files to download and cache on first semantic use. Note contents remain local.")
68
+ .addToggle((toggle) => {
69
+ toggle
70
+ .setValue(settings.embeddings.allowRemoteModels)
71
+ .onChange((allowRemoteModels) => {
72
+ void this.update({ embeddings: { allowRemoteModels } });
73
+ });
74
+ });
75
+ new Setting(this.containerEl)
76
+ .setName("Local model path")
77
+ .setDesc("Optional host path for locally supplied model files.")
78
+ .addText((text) => {
79
+ text
80
+ .setPlaceholder("/models/")
81
+ .setValue(settings.embeddings.localModelPath)
82
+ .onChange((localModelPath) => {
83
+ void this.update({ embeddings: { localModelPath } }, false);
84
+ });
85
+ });
86
+ new Setting(this.containerEl)
87
+ .setName("Semantic index")
88
+ .setDesc("Rebuild document chunks and embeddings using current settings.")
89
+ .addButton((button) => {
90
+ this.configureRebuildButton(button);
91
+ button.onClick(() => void this.rebuild());
92
+ });
93
+ this.addSlider("Chunk target size", "Approximate character target for each indexed chunk.", settings.chunking.targetChars, 400, 3000, 50, (targetChars) => ({ chunking: { targetChars } }));
94
+ this.addSlider("Breakpoint search window", "Distance searched for a stronger heading or paragraph boundary.", settings.chunking.breakpointWindowChars, 80, 1000, 20, (breakpointWindowChars) => ({ chunking: { breakpointWindowChars } }));
95
+ this.addSlider("Breakpoint decay", "How strongly distance from the ideal cutoff penalizes a boundary.", settings.chunking.breakpointDecay, 0.1, 1, 0.05, (breakpointDecay) => ({ chunking: { breakpointDecay } }));
96
+ this.addSlider("Result limit", "Maximum files returned for one query.", settings.query.resultLimit, 10, 300, 10, (resultLimit) => ({ query: { resultLimit } }));
97
+ this.addSlider("Snippet length", "Maximum characters shown around each match.", settings.query.snippetLength, 80, 400, 20, (snippetLength) => ({ query: { snippetLength } }));
98
+ }
99
+ addSlider(name, description, value, min, max, step, patch) {
100
+ new Setting(this.containerEl)
101
+ .setName(name)
102
+ .setDesc(description)
103
+ .addSlider((slider) => {
104
+ slider
105
+ .setDynamicTooltip()
106
+ .setLimits(min, max, step)
107
+ .setValue(value)
108
+ .onChange((next) => void this.update(patch(Number(next)), false));
109
+ });
110
+ }
111
+ async update(patch, redisplay = true) {
112
+ await this.searchPlugin.updateSettings(patch);
113
+ if (redisplay)
114
+ this.display();
115
+ }
116
+ configureRebuildButton(button) {
117
+ button.setCta();
118
+ button
119
+ .setButtonText(this.rebuilding ? "Rebuilding…" : "Rebuild embeddings")
120
+ .setDisabled(this.rebuilding);
121
+ }
122
+ async rebuild() {
123
+ if (this.rebuilding)
124
+ return;
125
+ this.rebuilding = true;
126
+ this.display();
127
+ try {
128
+ const status = await this.searchPlugin.rebuildSemanticSearch();
129
+ new Notice(`Semantic search refreshed (${status.readyChunkCount}/${status.chunkCount} ready)`);
130
+ }
131
+ catch (error) {
132
+ new Notice(`Semantic search refresh failed: ${error instanceof Error ? error.message : String(error)}`);
133
+ }
134
+ finally {
135
+ this.rebuilding = false;
136
+ this.display();
137
+ }
138
+ }
139
+ }
@@ -0,0 +1,54 @@
1
+ import type { SearchEmbeddingProviderConfig } from "@lapis-notes/api";
2
+ export type SearchEmbeddingProviderSetting = "disabled" | "transformers-js";
3
+ export type SearchViewSortMode = "filename-asc" | "filename-desc" | "modified-desc" | "modified-asc" | "created-desc" | "created-asc";
4
+ export type SearchResultFacet = "all" | "markdown" | "canvas";
5
+ export type SearchRetrievalMode = "auto" | "lexical" | "vector" | "hybrid";
6
+ export declare function resolveSearchRetrievalModeForQuery(requested: SearchRetrievalMode, structured: boolean, allowSemanticStructuredQueries: boolean): SearchRetrievalMode;
7
+ export declare function resolveSearchSnippetLength(configuredLength: number, showMoreContext: boolean): number;
8
+ export declare const SEARCH_EMBEDDING_MODEL_OPTIONS: readonly ["Xenova/all-MiniLM-L6-v2", "onnx-community/bge-small-en-v1.5-ONNX", "nomic-ai/nomic-embed-text-v1.5", "Xenova/paraphrase-multilingual-MiniLM-L12-v2"];
9
+ export declare const CUSTOM_SEARCH_EMBEDDING_MODEL_OPTION = "custom";
10
+ export interface SearchPluginSettings {
11
+ chunking: {
12
+ targetChars: number;
13
+ breakpointWindowChars: number;
14
+ breakpointDecay: number;
15
+ };
16
+ query: {
17
+ resultLimit: number;
18
+ snippetLength: number;
19
+ };
20
+ embeddings: {
21
+ provider: SearchEmbeddingProviderSetting;
22
+ modelId: string;
23
+ allowRemoteModels: boolean;
24
+ localModelPath: string;
25
+ };
26
+ view: {
27
+ sortMode: SearchViewSortMode;
28
+ collapseResults: boolean;
29
+ showMoreContext: boolean;
30
+ explainSearchTerms: boolean;
31
+ semanticSearchInStructuredQueries: boolean;
32
+ matchCase: boolean;
33
+ recentSearches: string[];
34
+ resultFacet: SearchResultFacet;
35
+ retrievalMode: SearchRetrievalMode;
36
+ };
37
+ }
38
+ export type SearchPluginSettingsPatch = {
39
+ chunking?: Partial<SearchPluginSettings["chunking"]>;
40
+ query?: Partial<SearchPluginSettings["query"]>;
41
+ embeddings?: Partial<SearchPluginSettings["embeddings"]>;
42
+ view?: Partial<SearchPluginSettings["view"]>;
43
+ };
44
+ export declare const SEARCH_VIEW_SORT_OPTIONS: ReadonlyArray<{
45
+ value: SearchViewSortMode;
46
+ label: string;
47
+ }>;
48
+ export declare const DEFAULT_SEARCH_SETTINGS: SearchPluginSettings;
49
+ export declare function isPresetSearchEmbeddingModelId(modelId: string): boolean;
50
+ export declare function getSearchEmbeddingModelOptionValue(modelId: string): string;
51
+ export declare function getSearchEmbeddingCustomOptionLabel(modelId: string): string;
52
+ export declare function mergeSearchSettings(stored: SearchPluginSettingsPatch | null | undefined): SearchPluginSettings;
53
+ export declare function patchSearchSettings(current: SearchPluginSettings, patch: SearchPluginSettingsPatch): SearchPluginSettings;
54
+ export declare function resolveSearchEmbeddingProviderConfig(settings: SearchPluginSettings): SearchEmbeddingProviderConfig | null;
@@ -0,0 +1,107 @@
1
+ export function resolveSearchRetrievalModeForQuery(requested, structured, allowSemanticStructuredQueries) {
2
+ return structured && !allowSemanticStructuredQueries ? "lexical" : requested;
3
+ }
4
+ export function resolveSearchSnippetLength(configuredLength, showMoreContext) {
5
+ return showMoreContext ? Math.max(configuredLength, 280) : configuredLength;
6
+ }
7
+ export const SEARCH_EMBEDDING_MODEL_OPTIONS = [
8
+ "Xenova/all-MiniLM-L6-v2",
9
+ "onnx-community/bge-small-en-v1.5-ONNX",
10
+ "nomic-ai/nomic-embed-text-v1.5",
11
+ "Xenova/paraphrase-multilingual-MiniLM-L12-v2",
12
+ ];
13
+ export const CUSTOM_SEARCH_EMBEDDING_MODEL_OPTION = "custom";
14
+ export const SEARCH_VIEW_SORT_OPTIONS = [
15
+ { value: "filename-asc", label: "Filename (A to Z)" },
16
+ { value: "filename-desc", label: "Filename (Z to A)" },
17
+ { value: "modified-desc", label: "Modified (new to old)" },
18
+ { value: "modified-asc", label: "Modified (old to new)" },
19
+ { value: "created-desc", label: "Created (new to old)" },
20
+ { value: "created-asc", label: "Created (old to new)" },
21
+ ];
22
+ export const DEFAULT_SEARCH_SETTINGS = {
23
+ chunking: {
24
+ targetChars: 1200,
25
+ breakpointWindowChars: 320,
26
+ breakpointDecay: 0.7,
27
+ },
28
+ query: {
29
+ resultLimit: 100,
30
+ snippetLength: 160,
31
+ },
32
+ embeddings: {
33
+ provider: "disabled",
34
+ modelId: "Xenova/all-MiniLM-L6-v2",
35
+ allowRemoteModels: true,
36
+ localModelPath: "",
37
+ },
38
+ view: {
39
+ sortMode: "filename-asc",
40
+ collapseResults: true,
41
+ showMoreContext: false,
42
+ explainSearchTerms: false,
43
+ semanticSearchInStructuredQueries: false,
44
+ matchCase: false,
45
+ recentSearches: [],
46
+ resultFacet: "all",
47
+ retrievalMode: "auto",
48
+ },
49
+ };
50
+ function boundedRecentSearches(value) {
51
+ if (!Array.isArray(value))
52
+ return [];
53
+ return value
54
+ .filter((item) => typeof item === "string")
55
+ .map((item) => item.trim())
56
+ .filter(Boolean)
57
+ .filter((item, index, items) => items.indexOf(item) === index)
58
+ .slice(0, 10);
59
+ }
60
+ export function isPresetSearchEmbeddingModelId(modelId) {
61
+ return SEARCH_EMBEDDING_MODEL_OPTIONS.includes(modelId.trim());
62
+ }
63
+ export function getSearchEmbeddingModelOptionValue(modelId) {
64
+ return isPresetSearchEmbeddingModelId(modelId)
65
+ ? modelId.trim()
66
+ : CUSTOM_SEARCH_EMBEDDING_MODEL_OPTION;
67
+ }
68
+ export function getSearchEmbeddingCustomOptionLabel(modelId) {
69
+ const trimmed = modelId.trim();
70
+ return !trimmed || isPresetSearchEmbeddingModelId(trimmed)
71
+ ? "Custom"
72
+ : `Custom (${trimmed})`;
73
+ }
74
+ export function mergeSearchSettings(stored) {
75
+ return {
76
+ chunking: { ...DEFAULT_SEARCH_SETTINGS.chunking, ...stored?.chunking },
77
+ query: { ...DEFAULT_SEARCH_SETTINGS.query, ...stored?.query },
78
+ embeddings: {
79
+ ...DEFAULT_SEARCH_SETTINGS.embeddings,
80
+ ...stored?.embeddings,
81
+ },
82
+ view: {
83
+ ...DEFAULT_SEARCH_SETTINGS.view,
84
+ ...stored?.view,
85
+ recentSearches: boundedRecentSearches(stored?.view?.recentSearches),
86
+ },
87
+ };
88
+ }
89
+ export function patchSearchSettings(current, patch) {
90
+ return mergeSearchSettings({
91
+ chunking: { ...current.chunking, ...patch.chunking },
92
+ query: { ...current.query, ...patch.query },
93
+ embeddings: { ...current.embeddings, ...patch.embeddings },
94
+ view: { ...current.view, ...patch.view },
95
+ });
96
+ }
97
+ export function resolveSearchEmbeddingProviderConfig(settings) {
98
+ if (settings.embeddings.provider === "disabled")
99
+ return null;
100
+ return {
101
+ kind: "transformers-js",
102
+ modelId: settings.embeddings.modelId.trim() ||
103
+ DEFAULT_SEARCH_SETTINGS.embeddings.modelId,
104
+ allowRemoteModels: settings.embeddings.allowRemoteModels,
105
+ localModelPath: settings.embeddings.localModelPath.trim() || undefined,
106
+ };
107
+ }
@@ -0,0 +1,8 @@
1
+ import type { TFile } from "@lapis-notes/api";
2
+ import { type SearchViewSortMode } from "./search-settings";
3
+ type SortableFile = Pick<TFile, "name" | "path" | "stat">;
4
+ export declare function formatSearchViewSortLabel(mode: SearchViewSortMode): string;
5
+ export declare function sortSearchResults<T extends {
6
+ file: SortableFile;
7
+ }>(items: T[], mode: SearchViewSortMode): T[];
8
+ export {};
@@ -0,0 +1,36 @@
1
+ import { SEARCH_VIEW_SORT_OPTIONS, } from "./search-settings";
2
+ function compareText(left, right) {
3
+ return left.localeCompare(right, undefined, {
4
+ sensitivity: "base",
5
+ numeric: true,
6
+ });
7
+ }
8
+ export function formatSearchViewSortLabel(mode) {
9
+ return (SEARCH_VIEW_SORT_OPTIONS.find((option) => option.value === mode)?.label ??
10
+ SEARCH_VIEW_SORT_OPTIONS[0].label);
11
+ }
12
+ export function sortSearchResults(items, mode) {
13
+ return [...items].sort((left, right) => {
14
+ switch (mode) {
15
+ case "filename-desc":
16
+ return (compareText(right.file.name, left.file.name) ||
17
+ compareText(left.file.path, right.file.path));
18
+ case "modified-desc":
19
+ return (right.file.stat.mtime - left.file.stat.mtime ||
20
+ compareText(left.file.path, right.file.path));
21
+ case "modified-asc":
22
+ return (left.file.stat.mtime - right.file.stat.mtime ||
23
+ compareText(left.file.path, right.file.path));
24
+ case "created-desc":
25
+ return (right.file.stat.ctime - left.file.stat.ctime ||
26
+ compareText(left.file.path, right.file.path));
27
+ case "created-asc":
28
+ return (left.file.stat.ctime - right.file.stat.ctime ||
29
+ compareText(left.file.path, right.file.path));
30
+ case "filename-asc":
31
+ default:
32
+ return (compareText(left.file.name, right.file.name) ||
33
+ compareText(left.file.path, right.file.path));
34
+ }
35
+ });
36
+ }
@@ -0,0 +1,3 @@
1
+ import type { App } from "@lapis-notes/api";
2
+ import type { SearchManager, SearchRuntimeStatus } from "./search-manager";
3
+ export declare function reconcileSearchAfterMetadata(app: Pick<App, "metadataCache">, manager: Pick<SearchManager, "reconcileStartup">): Promise<SearchRuntimeStatus>;
@@ -0,0 +1,4 @@
1
+ export async function reconcileSearchAfterMetadata(app, manager) {
2
+ await app.metadataCache.load();
3
+ return manager.reconcileStartup();
4
+ }