@agent-native/core 0.184.0 → 0.184.1-nightly-20260922211241
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/corpus/README.md +1 -1
- package/corpus/templates/analytics/changelog/2026-09-22-apps-start-with-an-app-shaped-skeleton-while-session-data-lo.md +6 -0
- package/corpus/templates/calendar/changelog/2026-09-22-apps-start-with-an-app-shaped-skeleton-while-session-data-lo.md +6 -0
- package/corpus/templates/clips/changelog/2026-09-22-apps-start-with-an-app-shaped-skeleton-while-session-data-lo.md +6 -0
- package/corpus/templates/clips/changelog/2026-09-22-rewind-uses-less-storage-by-default.md +6 -0
- package/corpus/templates/clips/desktop/src/app.tsx +1 -1
- package/corpus/templates/clips/desktop/src-tauri/src/config.rs +30 -3
- package/corpus/templates/clips/desktop/src-tauri/src/screen_memory.rs +1 -1
- package/corpus/templates/content/AGENTS.md +1 -1
- package/corpus/templates/content/actions/_document-search-ranking.ts +191 -0
- package/corpus/templates/content/actions/search-documents.ts +184 -49
- package/corpus/templates/content/changelog/2026-09-21-search-ranks-title-matches-first.md +6 -0
- package/corpus/templates/content/docs/product/capabilities/content.knowledge.search.md +11 -8
- package/corpus/templates/design/.generated/bridge/editor-chrome.generated.ts +10 -3
- package/corpus/templates/design/.generated/bridge/nav.generated.ts +1 -0
- package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +250 -22
- package/corpus/templates/design/app/components/design/bridge/nav.bridge.ts +16 -0
- package/corpus/templates/design/app/components/design/multi-screen/board-surface-html.ts +32 -0
- package/corpus/templates/design/app/components/design/multi-screen/types.ts +4 -2
- package/corpus/templates/design/app/pages/design-editor/commands/cross-screen-element-drop.ts +22 -1
- package/corpus/templates/design/changelog/2026-09-22-apps-start-with-an-app-shaped-skeleton-while-session-data-lo.md +6 -0
- package/corpus/templates/design/changelog/2026-09-22-blocks-can-be-selected-with-one-click-inside-screens.md +6 -0
- package/corpus/templates/design/e2e/drag-and-drop.shared.ts +10 -5
- package/corpus/templates/mail/app/components/layout/AppLayout.tsx +136 -123
- package/corpus/templates/mail/changelog/2026-09-22-apps-start-with-an-app-shaped-skeleton-while-session-data-lo.md +6 -0
- package/corpus/templates/slides/changelog/2026-09-22-apps-start-with-an-app-shaped-skeleton-while-session-data-lo.md +6 -0
- package/dist/agent/engine/anthropic-engine.d.ts +1 -1
- package/dist/agent/model-config.d.ts +8 -8
- package/dist/agent/model-config.js +43 -20
- package/dist/client/AppShellSkeleton.d.ts +5 -0
- package/dist/client/AppShellSkeleton.js +69 -0
- package/dist/client/ClientOnly.d.ts +1 -1
- package/dist/client/ClientOnly.js +1 -1
- package/dist/client/LanguagePicker.d.ts +6 -0
- package/dist/client/LanguagePicker.js +128 -0
- package/dist/client/analytics.d.ts +3 -2
- package/dist/client/analytics.js +1 -1
- package/dist/client/app-providers.d.ts +3 -3
- package/dist/client/app-providers.js +53 -23
- package/dist/client/client-status-requests.d.ts +5 -0
- package/dist/client/client-status-requests.js +31 -18
- package/dist/client/i18n.d.ts +1 -6
- package/dist/client/i18n.js +3 -117
- package/dist/client/onboarding/first-run-startup-gate.js +14 -3
- package/dist/client/require-session.d.ts +1 -1
- package/dist/client/require-session.js +3 -3
- package/dist/client/session-replay-context.d.ts +16 -0
- package/dist/client/session-replay-context.js +93 -0
- package/dist/client/session-replay.d.ts +2 -26
- package/dist/client/session-replay.js +1 -64
- package/dist/client/settings/SettingsPanel.js +12 -8
- package/dist/deploy/build.js +19 -29
- package/dist/notifications/routes.d.ts +1 -1
- package/dist/observability/routes.d.ts +5 -5
- package/dist/secrets/routes.d.ts +6 -6
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/core-routes-plugin.d.ts +1 -1
- package/dist/shared/reasoning-effort.js +14 -20
- package/dist/shared/ssr-session-bootstrap.d.ts +3 -0
- package/dist/shared/ssr-session-bootstrap.js +44 -0
- package/dist/triggers/actions/manage-automation.d.ts +2 -2
- package/package.json +4 -4
package/corpus/README.md
CHANGED
|
@@ -396,7 +396,7 @@ const DEFAULT_SCREEN_MEMORY_CONFIG = {
|
|
|
396
396
|
enabled: false,
|
|
397
397
|
paused: false,
|
|
398
398
|
retentionHours: 8,
|
|
399
|
-
maxBytes:
|
|
399
|
+
maxBytes: 5 * 1024 * 1024 * 1024,
|
|
400
400
|
segmentSeconds: 5 * 60,
|
|
401
401
|
sampleIntervalSeconds: 10,
|
|
402
402
|
captureMode: "visuals" as const,
|
|
@@ -3,7 +3,7 @@ use std::path::PathBuf;
|
|
|
3
3
|
use tauri::{AppHandle, Emitter, Manager};
|
|
4
4
|
use tauri_plugin_autostart::ManagerExt as AutostartManagerExt;
|
|
5
5
|
|
|
6
|
-
const FEATURE_CONFIG_VERSION: u32 =
|
|
6
|
+
const FEATURE_CONFIG_VERSION: u32 = 2;
|
|
7
7
|
|
|
8
8
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
9
9
|
#[serde(rename_all = "camelCase")]
|
|
@@ -211,7 +211,7 @@ fn default_screen_memory_exclude_private_windows() -> bool {
|
|
|
211
211
|
}
|
|
212
212
|
|
|
213
213
|
fn default_screen_memory_max_bytes() -> u64 {
|
|
214
|
-
|
|
214
|
+
5 * 1024 * 1024 * 1024
|
|
215
215
|
}
|
|
216
216
|
|
|
217
217
|
fn default_screen_memory_segment_seconds() -> u64 {
|
|
@@ -253,8 +253,13 @@ impl Default for FeatureConfig {
|
|
|
253
253
|
}
|
|
254
254
|
|
|
255
255
|
fn migrate_feature_config(mut config: FeatureConfig) -> FeatureConfig {
|
|
256
|
-
if config.config_version <
|
|
256
|
+
if config.config_version < 1 {
|
|
257
257
|
config.auto_hide_popover_enabled = true;
|
|
258
|
+
}
|
|
259
|
+
if config.config_version < 2 && config.screen_memory.max_bytes == 20 * 1024 * 1024 * 1024 {
|
|
260
|
+
config.screen_memory.max_bytes = default_screen_memory_max_bytes();
|
|
261
|
+
}
|
|
262
|
+
if config.config_version < FEATURE_CONFIG_VERSION {
|
|
258
263
|
config.config_version = FEATURE_CONFIG_VERSION;
|
|
259
264
|
}
|
|
260
265
|
config
|
|
@@ -482,6 +487,28 @@ mod tests {
|
|
|
482
487
|
assert!(!opt_out.auto_hide_popover_enabled);
|
|
483
488
|
}
|
|
484
489
|
|
|
490
|
+
#[test]
|
|
491
|
+
fn migrates_the_previous_screen_memory_storage_default() {
|
|
492
|
+
let mut config = FeatureConfig::default();
|
|
493
|
+
config.config_version = 1;
|
|
494
|
+
config.auto_hide_popover_enabled = false;
|
|
495
|
+
config.screen_memory.max_bytes = 20 * 1024 * 1024 * 1024;
|
|
496
|
+
|
|
497
|
+
let migrated = migrate_feature_config(config);
|
|
498
|
+
|
|
499
|
+
assert_eq!(migrated.config_version, FEATURE_CONFIG_VERSION);
|
|
500
|
+
assert_eq!(migrated.screen_memory.max_bytes, 5 * 1024 * 1024 * 1024);
|
|
501
|
+
assert!(!migrated.auto_hide_popover_enabled);
|
|
502
|
+
|
|
503
|
+
let mut custom = FeatureConfig::default();
|
|
504
|
+
custom.config_version = 1;
|
|
505
|
+
custom.screen_memory.max_bytes = 50 * 1024 * 1024 * 1024;
|
|
506
|
+
assert_eq!(
|
|
507
|
+
migrate_feature_config(custom).screen_memory.max_bytes,
|
|
508
|
+
50 * 1024 * 1024 * 1024
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
|
|
485
512
|
#[test]
|
|
486
513
|
fn feature_config_migration_updates_legacy_default_but_preserves_current_opt_out() {
|
|
487
514
|
let legacy: FeatureConfig = serde_json::from_value(serde_json::json!({
|
|
@@ -3247,7 +3247,7 @@ fn prune_segments(app: &AppHandle, config: &ScreenMemoryConfig) -> Result<(), St
|
|
|
3247
3247
|
let expired = ended_at.map(|value| value < cutoff).unwrap_or(false);
|
|
3248
3248
|
kept_bytes = kept_bytes.saturating_add(segment.bytes);
|
|
3249
3249
|
if (expired || kept_bytes > config.max_bytes) && !segment_is_pinned(app, &segment.id) {
|
|
3250
|
-
|
|
3250
|
+
delete_segment(app, &segment.id)?;
|
|
3251
3251
|
}
|
|
3252
3252
|
}
|
|
3253
3253
|
prune_events(app, cutoff)?;
|
|
@@ -63,7 +63,7 @@ Read the relevant skill before deeper work:
|
|
|
63
63
|
| `navigate` | Move the UI to a document, comments, media, or settings |
|
|
64
64
|
| `refresh-list` | Repaint the sidebar after an out-of-band mutation |
|
|
65
65
|
| `list-documents` | Document metadata tree, without bodies |
|
|
66
|
-
| `search-documents` | Title
|
|
66
|
+
| `search-documents` | Title-first search with snippets |
|
|
67
67
|
| `get-document` | One document with full content |
|
|
68
68
|
| `pull-document` | Flush live collab state, then read (external edits) |
|
|
69
69
|
| `get-blocks-field-word-count` | Count one exact Blocks field; omit `propertyId` for the primary Content body |
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { and, or, sql, type SQL, type SQLWrapper } from "drizzle-orm";
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
ParsedSearchQuery,
|
|
5
|
+
SearchQueryGroup,
|
|
6
|
+
SearchQueryTerm,
|
|
7
|
+
} from "../shared/search-query.js";
|
|
8
|
+
|
|
9
|
+
function escapeLike(value: string) {
|
|
10
|
+
return value.replace(/([\\%_])/g, "\\$1");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function substringMatch(column: SQLWrapper, term: SearchQueryTerm): SQL {
|
|
14
|
+
return sql`${column} ILIKE ${`%${escapeLike(term.text)}%`} ESCAPE '\\'`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function escapeRegex(value: string) {
|
|
18
|
+
return value.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function regexTerm(value: string) {
|
|
22
|
+
return escapeRegex(value.trim()).replace(/\s+/g, "[[:space:]]+");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function searchQueryProximityPattern(parsed: ParsedSearchQuery) {
|
|
26
|
+
if (
|
|
27
|
+
parsed.groups.length < 2 ||
|
|
28
|
+
parsed.groups.some(
|
|
29
|
+
(group) => group.terms.length !== 1 || group.terms[0]!.titleOnly,
|
|
30
|
+
)
|
|
31
|
+
) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
return parsed.groups
|
|
35
|
+
.map((group) => regexTerm(group.terms[0]!.text))
|
|
36
|
+
.join("(.|\\n){0,240}");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function wordPrefixMatch(column: SQLWrapper, term: SearchQueryTerm): SQL {
|
|
40
|
+
const pattern = regexTerm(term.text);
|
|
41
|
+
return sql`${column} ~* ${`(^|[^[:alnum:]_])${pattern}`}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function groupSubstringMatch(column: SQLWrapper, group: SearchQueryGroup): SQL {
|
|
45
|
+
return or(...group.terms.map((term) => substringMatch(column, term)))!;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function nonTitleGroupSubstringMatch(
|
|
49
|
+
column: SQLWrapper,
|
|
50
|
+
group: SearchQueryGroup,
|
|
51
|
+
): SQL {
|
|
52
|
+
const terms = group.terms.filter((term) => !term.titleOnly);
|
|
53
|
+
return terms.length
|
|
54
|
+
? or(...terms.map((term) => substringMatch(column, term)))!
|
|
55
|
+
: sql`false`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function groupWordPrefixMatch(
|
|
59
|
+
column: SQLWrapper,
|
|
60
|
+
group: SearchQueryGroup,
|
|
61
|
+
): SQL {
|
|
62
|
+
return or(...group.terms.map((term) => wordPrefixMatch(column, term)))!;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function countMatchingGroups(
|
|
66
|
+
column: SQLWrapper,
|
|
67
|
+
groups: SearchQueryGroup[],
|
|
68
|
+
nonTitleOnly = false,
|
|
69
|
+
): SQL<number> {
|
|
70
|
+
if (!groups.length) return sql<number>`0`;
|
|
71
|
+
return sql<number>`(${sql.join(
|
|
72
|
+
groups.map(
|
|
73
|
+
(group) =>
|
|
74
|
+
sql`case when ${nonTitleOnly ? nonTitleGroupSubstringMatch(column, group) : groupSubstringMatch(column, group)} then 1 else 0 end`,
|
|
75
|
+
),
|
|
76
|
+
sql` + `,
|
|
77
|
+
)})`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface DocumentSearchRankingColumns {
|
|
81
|
+
title: SQLWrapper;
|
|
82
|
+
description: SQLWrapper;
|
|
83
|
+
content: SQLWrapper;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface DocumentSearchRankingOptions {
|
|
87
|
+
includeNonTitleFields?: boolean;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function bodyProximityScore(
|
|
91
|
+
parsed: ParsedSearchQuery,
|
|
92
|
+
content: SQLWrapper,
|
|
93
|
+
): SQL<number> {
|
|
94
|
+
if (
|
|
95
|
+
parsed.groups.length < 2 ||
|
|
96
|
+
parsed.groups.some(
|
|
97
|
+
(group) => group.terms.length !== 1 || group.terms[0]!.titleOnly,
|
|
98
|
+
)
|
|
99
|
+
) {
|
|
100
|
+
return sql<number>`case when true then 0 else 0 end`;
|
|
101
|
+
}
|
|
102
|
+
const phrase = parsed.groups
|
|
103
|
+
.map((group) => group.terms[0]!.text.trim())
|
|
104
|
+
.join(" ");
|
|
105
|
+
return sql<number>`case when ${content} ILIKE ${`%${escapeLike(phrase)}%`} ESCAPE '\\' then 1 else 0 end`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Returns lexicographic ranking keys. The first key is a protected match tier:
|
|
110
|
+
* no recency or repetition signal can move a body match above a title match.
|
|
111
|
+
*/
|
|
112
|
+
export function documentSearchRanking(
|
|
113
|
+
parsed: ParsedSearchQuery,
|
|
114
|
+
columns: DocumentSearchRankingColumns,
|
|
115
|
+
options: DocumentSearchRankingOptions = {},
|
|
116
|
+
) {
|
|
117
|
+
const groups = parsed.groups;
|
|
118
|
+
const includeNonTitleFields = options.includeNonTitleFields ?? true;
|
|
119
|
+
const normalizedTitle = sql<string>`regexp_replace(lower(trim(coalesce(${columns.title}, ''))), '\\s+', ' ', 'g')`;
|
|
120
|
+
const simpleQueries =
|
|
121
|
+
groups.length > 0 && groups.every((group) => group.terms.length === 1)
|
|
122
|
+
? [groups.map((group) => group.terms[0]!.text.trim()).join(" ")]
|
|
123
|
+
: groups.length === 1
|
|
124
|
+
? groups[0]!.terms.map((term) => term.text.trim())
|
|
125
|
+
: [];
|
|
126
|
+
const normalizedSimpleQueries = simpleQueries.map((query) =>
|
|
127
|
+
query.toLocaleLowerCase().replace(/\s+/g, " "),
|
|
128
|
+
);
|
|
129
|
+
const guardNormalizedTitle = groups.every((group) =>
|
|
130
|
+
group.terms.every((term) => !/\s/.test(term.text)),
|
|
131
|
+
);
|
|
132
|
+
const allTitleSubstrings = groups.length
|
|
133
|
+
? and(...groups.map((group) => groupSubstringMatch(columns.title, group)))!
|
|
134
|
+
: sql`false`;
|
|
135
|
+
const allTitleWordPrefixes = groups.length
|
|
136
|
+
? and(...groups.map((group) => groupWordPrefixMatch(columns.title, group)))!
|
|
137
|
+
: sql`false`;
|
|
138
|
+
const allTitleOrDescription = groups.length
|
|
139
|
+
? and(
|
|
140
|
+
...groups.map((group) =>
|
|
141
|
+
includeNonTitleFields
|
|
142
|
+
? or(
|
|
143
|
+
groupSubstringMatch(columns.title, group),
|
|
144
|
+
nonTitleGroupSubstringMatch(columns.description, group),
|
|
145
|
+
)!
|
|
146
|
+
: groupSubstringMatch(columns.title, group),
|
|
147
|
+
),
|
|
148
|
+
)!
|
|
149
|
+
: sql`false`;
|
|
150
|
+
const matchTier = sql<number>`case
|
|
151
|
+
when ${
|
|
152
|
+
normalizedSimpleQueries.length
|
|
153
|
+
? and(
|
|
154
|
+
guardNormalizedTitle ? allTitleSubstrings : sql`true`,
|
|
155
|
+
or(
|
|
156
|
+
...normalizedSimpleQueries.map(
|
|
157
|
+
(query) => sql`${normalizedTitle} = ${query}`,
|
|
158
|
+
),
|
|
159
|
+
)!,
|
|
160
|
+
)!
|
|
161
|
+
: sql`false`
|
|
162
|
+
} then 5
|
|
163
|
+
when ${
|
|
164
|
+
normalizedSimpleQueries.length
|
|
165
|
+
? and(
|
|
166
|
+
guardNormalizedTitle ? allTitleSubstrings : sql`true`,
|
|
167
|
+
or(
|
|
168
|
+
...normalizedSimpleQueries.map(
|
|
169
|
+
(query) =>
|
|
170
|
+
sql`${normalizedTitle} like ${`${escapeLike(query)}%`} escape '\\'`,
|
|
171
|
+
),
|
|
172
|
+
)!,
|
|
173
|
+
)!
|
|
174
|
+
: sql`false`
|
|
175
|
+
} then 4
|
|
176
|
+
when ${allTitleSubstrings} and ${allTitleWordPrefixes} then 3
|
|
177
|
+
when ${allTitleSubstrings} then 2
|
|
178
|
+
when ${allTitleOrDescription} then 1
|
|
179
|
+
else 0
|
|
180
|
+
end`;
|
|
181
|
+
return {
|
|
182
|
+
matchTier,
|
|
183
|
+
titleCoverage: countMatchingGroups(columns.title, groups),
|
|
184
|
+
descriptionCoverage: includeNonTitleFields
|
|
185
|
+
? countMatchingGroups(columns.description, groups, true)
|
|
186
|
+
: sql<number>`0::integer`,
|
|
187
|
+
bodyProximity: includeNonTitleFields
|
|
188
|
+
? bodyProximityScore(parsed, columns.content)
|
|
189
|
+
: sql<number>`0::integer`,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
@@ -32,6 +32,10 @@ import {
|
|
|
32
32
|
documentDiscoveryPagination,
|
|
33
33
|
documentDiscoveryWhere,
|
|
34
34
|
} from "./_document-discovery-query.js";
|
|
35
|
+
import {
|
|
36
|
+
documentSearchRanking,
|
|
37
|
+
searchQueryProximityPattern,
|
|
38
|
+
} from "./_document-search-ranking.js";
|
|
35
39
|
|
|
36
40
|
function escapeLike(s: string): string {
|
|
37
41
|
return s.replace(/([\\%_])/g, "\\$1");
|
|
@@ -45,15 +49,21 @@ function escapeLike(s: string): string {
|
|
|
45
49
|
// no-match case. The row is still returned either way.
|
|
46
50
|
function makeSnippet(content: string, query: string, radius = 120) {
|
|
47
51
|
const compact = content.replace(/\s+/g, " ").trim();
|
|
52
|
+
const compactQuery = query.replace(/\s+/g, " ").trim();
|
|
48
53
|
if (!compact) return "";
|
|
49
|
-
|
|
54
|
+
if (!compactQuery) {
|
|
55
|
+
return compact.length <= radius * 2
|
|
56
|
+
? compact
|
|
57
|
+
: `${compact.slice(0, radius * 2).trimEnd()}...`;
|
|
58
|
+
}
|
|
59
|
+
const index = compact.toLowerCase().indexOf(compactQuery.toLowerCase());
|
|
50
60
|
if (index < 0) {
|
|
51
61
|
return compact.length <= radius * 2
|
|
52
62
|
? compact
|
|
53
63
|
: `${compact.slice(0, radius * 2).trimEnd()}...`;
|
|
54
64
|
}
|
|
55
65
|
const start = Math.max(0, index - radius);
|
|
56
|
-
const end = Math.min(compact.length, index +
|
|
66
|
+
const end = Math.min(compact.length, index + compactQuery.length + radius);
|
|
57
67
|
return `${start > 0 ? "..." : ""}${compact.slice(start, end).trim()}${
|
|
58
68
|
end < compact.length ? "..." : ""
|
|
59
69
|
}`;
|
|
@@ -61,7 +71,7 @@ function makeSnippet(content: string, query: string, radius = 120) {
|
|
|
61
71
|
|
|
62
72
|
export default defineAction({
|
|
63
73
|
description:
|
|
64
|
-
'Search one bounded page of access-scoped documents by title and content, or find an exact title within a parent, space, and document type. The query supports Google-style operators: "exact phrase", -excludedTerm, OR between terms (uppercase), intitle:term; bare words combine with AND and %, _ match literally. Returns explicit pagination; follow nextOffset until hasMore is false. Returns metadata and snippets; use get-document for full content.',
|
|
74
|
+
'Search one relevance-ranked, bounded page of access-scoped documents by title and content, or find an exact title within a parent, space, and document type. Exact and partial title matches rank above description and body matches. The query supports Google-style operators: "exact phrase", -excludedTerm, OR between terms (uppercase), intitle:term; bare words combine with AND and %, _ match literally. Returns explicit pagination; follow nextOffset until hasMore is false. Returns metadata and snippets; use get-document for full content.',
|
|
65
75
|
deferLoading: false,
|
|
66
76
|
mcpTool: true,
|
|
67
77
|
schema: z
|
|
@@ -131,6 +141,7 @@ export default defineAction({
|
|
|
131
141
|
]),
|
|
132
142
|
];
|
|
133
143
|
let bodyNeedles: string[] = [];
|
|
144
|
+
let parsedQuery: ReturnType<typeof parseSearchQuery> | null = null;
|
|
134
145
|
const queryTermPredicate = (term: SearchQueryTerm): SQL => {
|
|
135
146
|
const pattern = `%${escapeLike(term.text)}%`;
|
|
136
147
|
const columns =
|
|
@@ -148,6 +159,7 @@ export default defineAction({
|
|
|
148
159
|
const matchPredicates: SQL[] = [];
|
|
149
160
|
if (args.query) {
|
|
150
161
|
const parsed = parseSearchQuery(args.query);
|
|
162
|
+
parsedQuery = parsed;
|
|
151
163
|
if (parsed.empty) {
|
|
152
164
|
// Punctuation-only input (lone `-`, empty quotes) matches nothing by
|
|
153
165
|
// design; report that as a loud empty page rather than every document.
|
|
@@ -169,7 +181,7 @@ export default defineAction({
|
|
|
169
181
|
.map((term) => term.text),
|
|
170
182
|
),
|
|
171
183
|
),
|
|
172
|
-
];
|
|
184
|
+
].slice(0, 256);
|
|
173
185
|
}
|
|
174
186
|
}
|
|
175
187
|
const where = documentDiscoveryWhere({
|
|
@@ -204,39 +216,110 @@ export default defineAction({
|
|
|
204
216
|
: undefined,
|
|
205
217
|
),
|
|
206
218
|
});
|
|
207
|
-
const [countRow] = await db
|
|
208
|
-
.select({ count: sql<number>`count(*)` })
|
|
209
|
-
.from(schema.documents)
|
|
210
|
-
.where(where);
|
|
211
|
-
const totalItems = Number(countRow?.count ?? 0);
|
|
212
|
-
|
|
213
219
|
// Project a bounded preview of `content` instead of the full column:
|
|
214
220
|
// document bodies can be multi-MB, and this action only returns a short
|
|
215
221
|
// snippet (use get-document for full content). In free-text mode the
|
|
216
|
-
// preview window is anchored at
|
|
217
|
-
// eligible positive
|
|
218
|
-
// so `makeSnippet`
|
|
222
|
+
// preview window is anchored at an in-body occurrence that keeps the most
|
|
223
|
+
// eligible positive terms in view. The selected term is projected with the
|
|
224
|
+
// window so `makeSnippet` preserves the complete match. Position and query
|
|
225
|
+
// order break ties;
|
|
219
226
|
// title-only matches and exactTitle mode keep the head projection. The
|
|
220
227
|
// true length still comes from SQL `length()` rather than reading `.length`
|
|
221
228
|
// off a truncated string. Mirrors the
|
|
222
229
|
// `substr`/`length` projection style in list-documents.ts; `position`,
|
|
223
230
|
// `substr`, and `length` all work in PostgreSQL and PGlite.
|
|
224
231
|
const normalizedContent = sql<string>`coalesce(${schema.documents.content}, '')`;
|
|
225
|
-
const
|
|
226
|
-
? sql
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
232
|
+
const bodyNeedleArray = bodyNeedles.length
|
|
233
|
+
? sql`array[${sql.join(
|
|
234
|
+
bodyNeedles.map((needle) => sql`${needle}`),
|
|
235
|
+
sql`, `,
|
|
236
|
+
)}]::text[]`
|
|
237
|
+
: undefined;
|
|
238
|
+
const maxOccurrencesPerNeedle = Math.max(
|
|
239
|
+
1,
|
|
240
|
+
Math.floor(256 / Math.max(1, bodyNeedles.length)),
|
|
241
|
+
);
|
|
242
|
+
const proximityPattern =
|
|
243
|
+
bodyNeedleArray && parsedQuery
|
|
244
|
+
? searchQueryProximityPattern(parsedQuery)
|
|
245
|
+
: null;
|
|
246
|
+
const proximityPosition = proximityPattern
|
|
247
|
+
? sql<number>`regexp_instr(${normalizedContent}, ${proximityPattern}, 1, 1, 0, 'i')`
|
|
248
|
+
: undefined;
|
|
249
|
+
// Inspect repeated occurrences so the snippet can prefer a later,
|
|
250
|
+
// denser passage. Cap each needle to keep pathological repeated-token
|
|
251
|
+
// documents from turning one result preview into an unbounded scan.
|
|
252
|
+
const fallbackBodyPosition =
|
|
253
|
+
bodyNeedles.length === 1
|
|
254
|
+
? sql<number>`nullif(position(lower(${bodyNeedles[0]!}) in lower(${normalizedContent})), 0)`
|
|
255
|
+
: bodyNeedleArray
|
|
256
|
+
? sql<number>`(
|
|
257
|
+
with recursive body_occurrence(needle, query_order, match_position, occurrence_number) as (
|
|
258
|
+
select candidate.needle, candidate.query_order,
|
|
259
|
+
position(lower(candidate.needle) in lower(${normalizedContent})), 1
|
|
260
|
+
from unnest(${bodyNeedleArray}) with ordinality as candidate(needle, query_order)
|
|
261
|
+
where position(lower(candidate.needle) in lower(${normalizedContent})) > 0
|
|
262
|
+
union all
|
|
263
|
+
select occurrence.needle, occurrence.query_order,
|
|
264
|
+
occurrence.match_position + position(
|
|
265
|
+
lower(occurrence.needle)
|
|
266
|
+
in substring(lower(${normalizedContent}) from occurrence.match_position + 1)
|
|
267
|
+
), occurrence.occurrence_number + 1
|
|
268
|
+
from body_occurrence as occurrence
|
|
269
|
+
where occurrence.occurrence_number < ${maxOccurrencesPerNeedle} and position(
|
|
270
|
+
lower(occurrence.needle)
|
|
271
|
+
in substring(lower(${normalizedContent}) from occurrence.match_position + 1)
|
|
272
|
+
) > 0
|
|
273
|
+
)
|
|
274
|
+
select candidate.match_position
|
|
275
|
+
from body_occurrence as candidate
|
|
276
|
+
order by (
|
|
277
|
+
select count(*)
|
|
278
|
+
from unnest(${bodyNeedleArray}) as nearby(needle)
|
|
279
|
+
where position(
|
|
280
|
+
lower(nearby.needle)
|
|
281
|
+
in lower(substr(
|
|
282
|
+
${normalizedContent},
|
|
283
|
+
greatest(1, candidate.match_position - 120),
|
|
284
|
+
240 + length(candidate.needle)
|
|
285
|
+
))
|
|
286
|
+
) > 0
|
|
287
|
+
) desc,
|
|
288
|
+
candidate.match_position,
|
|
289
|
+
candidate.query_order
|
|
234
290
|
limit 1
|
|
235
291
|
)`
|
|
236
|
-
|
|
237
|
-
const
|
|
238
|
-
|
|
292
|
+
: undefined;
|
|
293
|
+
const selectedBodyPosition =
|
|
294
|
+
proximityPosition && fallbackBodyPosition
|
|
295
|
+
? sql<number>`coalesce(nullif(${proximityPosition}, 0), ${fallbackBodyPosition})`
|
|
296
|
+
: fallbackBodyPosition;
|
|
297
|
+
const selectedBodyNeedle =
|
|
298
|
+
bodyNeedleArray && selectedBodyPosition
|
|
299
|
+
? sql<string>`(
|
|
300
|
+
select candidate.needle
|
|
301
|
+
from unnest(${bodyNeedleArray}) with ordinality as candidate(needle, query_order)
|
|
302
|
+
where lower(substr(${normalizedContent}, ${selectedBodyPosition}, length(candidate.needle))) = lower(candidate.needle)
|
|
303
|
+
order by candidate.query_order
|
|
304
|
+
limit 1
|
|
305
|
+
)`
|
|
306
|
+
: undefined;
|
|
307
|
+
const matchWindow = selectedBodyPosition
|
|
308
|
+
? sql<string>`case when ${selectedBodyPosition} is not null then substr(${normalizedContent}, greatest(1, ${selectedBodyPosition} - 120), least(5000, 240 + coalesce(length(${selectedBodyNeedle}), 0))) else substr(${normalizedContent}, 1, 5000) end`
|
|
239
309
|
: sql<string>`substr(${normalizedContent}, 1, 5000)`;
|
|
310
|
+
const ranking = parsedQuery?.groups.length
|
|
311
|
+
? documentSearchRanking(
|
|
312
|
+
parsedQuery,
|
|
313
|
+
{
|
|
314
|
+
title: schema.documents.title,
|
|
315
|
+
description: schema.documents.description,
|
|
316
|
+
content: normalizedContent,
|
|
317
|
+
},
|
|
318
|
+
{
|
|
319
|
+
includeNonTitleFields: args.searchFields !== "title",
|
|
320
|
+
},
|
|
321
|
+
)
|
|
322
|
+
: null;
|
|
240
323
|
const docs = await db
|
|
241
324
|
.select({
|
|
242
325
|
id: schema.documents.id,
|
|
@@ -244,11 +327,6 @@ export default defineAction({
|
|
|
244
327
|
title: schema.documents.title,
|
|
245
328
|
description: schema.documents.description,
|
|
246
329
|
icon: schema.documents.icon,
|
|
247
|
-
contentPreview: matchWindow,
|
|
248
|
-
snippetNeedle: selectedBodyNeedle
|
|
249
|
-
? sql<string>`coalesce(${selectedBodyNeedle}, '')`
|
|
250
|
-
: sql<string>`''`,
|
|
251
|
-
contentLength: sql<number>`length(${normalizedContent})`,
|
|
252
330
|
hideFromSearch: schema.documents.hideFromSearch,
|
|
253
331
|
updatedAt: schema.documents.updatedAt,
|
|
254
332
|
sourceKind: schema.documents.sourceKind,
|
|
@@ -264,12 +342,63 @@ export default defineAction({
|
|
|
264
342
|
),
|
|
265
343
|
),
|
|
266
344
|
)} then 'database' else 'page' end`,
|
|
345
|
+
totalItems: sql<number>`count(*) over()`,
|
|
267
346
|
})
|
|
268
347
|
.from(schema.documents)
|
|
269
348
|
.where(where)
|
|
270
|
-
.orderBy(
|
|
349
|
+
.orderBy(
|
|
350
|
+
...(ranking
|
|
351
|
+
? [
|
|
352
|
+
desc(ranking.matchTier),
|
|
353
|
+
desc(ranking.titleCoverage),
|
|
354
|
+
desc(ranking.descriptionCoverage),
|
|
355
|
+
// Phrase coherence is a minor body-only tie-breaker. Bound its
|
|
356
|
+
// full-body scan so broad searches keep predictable latency;
|
|
357
|
+
// protected field tiers still rank the complete result set.
|
|
358
|
+
desc(
|
|
359
|
+
sql<number>`case when count(*) over() <= 1000 then ${ranking.bodyProximity} else 0 end`,
|
|
360
|
+
),
|
|
361
|
+
]
|
|
362
|
+
: []),
|
|
363
|
+
desc(schema.documents.updatedAt),
|
|
364
|
+
asc(schema.documents.id),
|
|
365
|
+
)
|
|
271
366
|
.limit(args.limit)
|
|
272
367
|
.offset(args.offset);
|
|
368
|
+
const previews = docs.length
|
|
369
|
+
? await db
|
|
370
|
+
.select({
|
|
371
|
+
id: schema.documents.id,
|
|
372
|
+
contentPreview: matchWindow,
|
|
373
|
+
snippetNeedle: selectedBodyNeedle
|
|
374
|
+
? sql<string>`coalesce(${selectedBodyNeedle}, '')`
|
|
375
|
+
: sql<string>`''`,
|
|
376
|
+
contentLength: sql<number>`length(${normalizedContent})`,
|
|
377
|
+
})
|
|
378
|
+
.from(schema.documents)
|
|
379
|
+
.where(
|
|
380
|
+
and(
|
|
381
|
+
where,
|
|
382
|
+
inArray(
|
|
383
|
+
schema.documents.id,
|
|
384
|
+
docs.map((doc) => doc.id),
|
|
385
|
+
),
|
|
386
|
+
),
|
|
387
|
+
)
|
|
388
|
+
: [];
|
|
389
|
+
const previewById = new Map(
|
|
390
|
+
previews.map((preview) => [preview.id, preview]),
|
|
391
|
+
);
|
|
392
|
+
const totalItems = docs.length
|
|
393
|
+
? Number(docs[0]!.totalItems)
|
|
394
|
+
: Number(
|
|
395
|
+
(
|
|
396
|
+
await db
|
|
397
|
+
.select({ count: sql<number>`count(*)` })
|
|
398
|
+
.from(schema.documents)
|
|
399
|
+
.where(where)
|
|
400
|
+
)[0]?.count ?? 0,
|
|
401
|
+
);
|
|
273
402
|
|
|
274
403
|
const parentIds = [
|
|
275
404
|
...new Set(docs.flatMap((doc) => (doc.parentId ? [doc.parentId] : []))),
|
|
@@ -290,24 +419,30 @@ export default defineAction({
|
|
|
290
419
|
const parentById = new Map(parents.map((parent) => [parent.id, parent]));
|
|
291
420
|
|
|
292
421
|
return {
|
|
293
|
-
documents: docs.map((doc) =>
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
:
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
422
|
+
documents: docs.map((doc) => {
|
|
423
|
+
const preview = previewById.get(doc.id);
|
|
424
|
+
return {
|
|
425
|
+
id: doc.id,
|
|
426
|
+
parentId:
|
|
427
|
+
doc.parentId && parentById.has(doc.parentId) ? doc.parentId : null,
|
|
428
|
+
parentTitle: doc.parentId
|
|
429
|
+
? (parentById.get(doc.parentId)?.title ?? null)
|
|
430
|
+
: null,
|
|
431
|
+
documentType: doc.documentType,
|
|
432
|
+
sourceKind: doc.sourceKind,
|
|
433
|
+
sourceUpdatedAt: doc.sourceUpdatedAt,
|
|
434
|
+
title: doc.title,
|
|
435
|
+
description: doc.description,
|
|
436
|
+
icon: doc.icon,
|
|
437
|
+
snippet: makeSnippet(
|
|
438
|
+
preview?.contentPreview ?? "",
|
|
439
|
+
preview?.snippetNeedle ?? "",
|
|
440
|
+
),
|
|
441
|
+
contentLength: Number(preview?.contentLength) || 0,
|
|
442
|
+
hideFromSearch: parseDocumentHideFromSearch(doc.hideFromSearch),
|
|
443
|
+
updatedAt: doc.updatedAt,
|
|
444
|
+
};
|
|
445
|
+
}),
|
|
311
446
|
pagination: documentDiscoveryPagination({
|
|
312
447
|
offset: args.offset,
|
|
313
448
|
limit: args.limit,
|