@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.
Files changed (62) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/changelog/2026-09-22-apps-start-with-an-app-shaped-skeleton-while-session-data-lo.md +6 -0
  3. package/corpus/templates/calendar/changelog/2026-09-22-apps-start-with-an-app-shaped-skeleton-while-session-data-lo.md +6 -0
  4. package/corpus/templates/clips/changelog/2026-09-22-apps-start-with-an-app-shaped-skeleton-while-session-data-lo.md +6 -0
  5. package/corpus/templates/clips/changelog/2026-09-22-rewind-uses-less-storage-by-default.md +6 -0
  6. package/corpus/templates/clips/desktop/src/app.tsx +1 -1
  7. package/corpus/templates/clips/desktop/src-tauri/src/config.rs +30 -3
  8. package/corpus/templates/clips/desktop/src-tauri/src/screen_memory.rs +1 -1
  9. package/corpus/templates/content/AGENTS.md +1 -1
  10. package/corpus/templates/content/actions/_document-search-ranking.ts +191 -0
  11. package/corpus/templates/content/actions/search-documents.ts +184 -49
  12. package/corpus/templates/content/changelog/2026-09-21-search-ranks-title-matches-first.md +6 -0
  13. package/corpus/templates/content/docs/product/capabilities/content.knowledge.search.md +11 -8
  14. package/corpus/templates/design/.generated/bridge/editor-chrome.generated.ts +10 -3
  15. package/corpus/templates/design/.generated/bridge/nav.generated.ts +1 -0
  16. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +250 -22
  17. package/corpus/templates/design/app/components/design/bridge/nav.bridge.ts +16 -0
  18. package/corpus/templates/design/app/components/design/multi-screen/board-surface-html.ts +32 -0
  19. package/corpus/templates/design/app/components/design/multi-screen/types.ts +4 -2
  20. package/corpus/templates/design/app/pages/design-editor/commands/cross-screen-element-drop.ts +22 -1
  21. package/corpus/templates/design/changelog/2026-09-22-apps-start-with-an-app-shaped-skeleton-while-session-data-lo.md +6 -0
  22. package/corpus/templates/design/changelog/2026-09-22-blocks-can-be-selected-with-one-click-inside-screens.md +6 -0
  23. package/corpus/templates/design/e2e/drag-and-drop.shared.ts +10 -5
  24. package/corpus/templates/mail/app/components/layout/AppLayout.tsx +136 -123
  25. package/corpus/templates/mail/changelog/2026-09-22-apps-start-with-an-app-shaped-skeleton-while-session-data-lo.md +6 -0
  26. package/corpus/templates/slides/changelog/2026-09-22-apps-start-with-an-app-shaped-skeleton-while-session-data-lo.md +6 -0
  27. package/dist/agent/engine/anthropic-engine.d.ts +1 -1
  28. package/dist/agent/model-config.d.ts +8 -8
  29. package/dist/agent/model-config.js +43 -20
  30. package/dist/client/AppShellSkeleton.d.ts +5 -0
  31. package/dist/client/AppShellSkeleton.js +69 -0
  32. package/dist/client/ClientOnly.d.ts +1 -1
  33. package/dist/client/ClientOnly.js +1 -1
  34. package/dist/client/LanguagePicker.d.ts +6 -0
  35. package/dist/client/LanguagePicker.js +128 -0
  36. package/dist/client/analytics.d.ts +3 -2
  37. package/dist/client/analytics.js +1 -1
  38. package/dist/client/app-providers.d.ts +3 -3
  39. package/dist/client/app-providers.js +53 -23
  40. package/dist/client/client-status-requests.d.ts +5 -0
  41. package/dist/client/client-status-requests.js +31 -18
  42. package/dist/client/i18n.d.ts +1 -6
  43. package/dist/client/i18n.js +3 -117
  44. package/dist/client/onboarding/first-run-startup-gate.js +14 -3
  45. package/dist/client/require-session.d.ts +1 -1
  46. package/dist/client/require-session.js +3 -3
  47. package/dist/client/session-replay-context.d.ts +16 -0
  48. package/dist/client/session-replay-context.js +93 -0
  49. package/dist/client/session-replay.d.ts +2 -26
  50. package/dist/client/session-replay.js +1 -64
  51. package/dist/client/settings/SettingsPanel.js +12 -8
  52. package/dist/deploy/build.js +19 -29
  53. package/dist/notifications/routes.d.ts +1 -1
  54. package/dist/observability/routes.d.ts +5 -5
  55. package/dist/secrets/routes.d.ts +6 -6
  56. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  57. package/dist/server/core-routes-plugin.d.ts +1 -1
  58. package/dist/shared/reasoning-effort.js +14 -20
  59. package/dist/shared/ssr-session-bootstrap.d.ts +3 -0
  60. package/dist/shared/ssr-session-bootstrap.js +44 -0
  61. package/dist/triggers/actions/manage-automation.d.ts +2 -2
  62. package/package.json +4 -4
package/corpus/README.md CHANGED
@@ -31,4 +31,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
31
31
 
32
32
  ## Generated Counts
33
33
 
34
- - template files: 9718
34
+ - template files: 9728
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: improved
3
+ date: 2026-09-22
4
+ ---
5
+
6
+ Apps start with an app-shaped skeleton while session data loads immediately.
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: improved
3
+ date: 2026-09-22
4
+ ---
5
+
6
+ Apps start with an app-shaped skeleton while session data loads immediately.
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: improved
3
+ date: 2026-09-22
4
+ ---
5
+
6
+ Apps start with an app-shaped skeleton while session data loads immediately.
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-09-22
4
+ ---
5
+
6
+ Rewind keeps its rolling screen history within a smaller default storage limit.
@@ -396,7 +396,7 @@ const DEFAULT_SCREEN_MEMORY_CONFIG = {
396
396
  enabled: false,
397
397
  paused: false,
398
398
  retentionHours: 8,
399
- maxBytes: 20 * 1024 * 1024 * 1024,
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 = 1;
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
- 20 * 1024 * 1024 * 1024
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 < FEATURE_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
- let _ = delete_segment(app, &segment.id);
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 and content search with snippets |
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
- const index = compact.toLowerCase().indexOf(query.toLowerCase());
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 + query.length + radius);
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 the earliest in-body occurrence of an
217
- // eligible positive term. The selected term is projected with the window
218
- // so `makeSnippet` centers on the same match. Query order breaks ties;
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 selectedBodyNeedle = bodyNeedles.length
226
- ? sql<string>`(
227
- select candidate.needle
228
- from unnest(array[${sql.join(
229
- bodyNeedles.map((needle) => sql`${needle}`),
230
- sql`, `,
231
- )}]::text[]) with ordinality as candidate(needle, query_order)
232
- where position(lower(candidate.needle) in lower(${normalizedContent})) > 0
233
- order by position(lower(candidate.needle) in lower(${normalizedContent})), candidate.query_order
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
- : undefined;
237
- const matchWindow = selectedBodyNeedle
238
- ? sql<string>`case when ${selectedBodyNeedle} is not null then substr(${normalizedContent}, greatest(1, position(lower(${selectedBodyNeedle}) in lower(${normalizedContent})) - 120), 240 + length(${selectedBodyNeedle})) else substr(${normalizedContent}, 1, 5000) end`
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(desc(schema.documents.updatedAt), asc(schema.documents.id))
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
- id: doc.id,
295
- parentId:
296
- doc.parentId && parentById.has(doc.parentId) ? doc.parentId : null,
297
- parentTitle: doc.parentId
298
- ? (parentById.get(doc.parentId)?.title ?? null)
299
- : null,
300
- documentType: doc.documentType,
301
- sourceKind: doc.sourceKind,
302
- sourceUpdatedAt: doc.sourceUpdatedAt,
303
- title: doc.title,
304
- description: doc.description,
305
- icon: doc.icon,
306
- snippet: makeSnippet(doc.contentPreview, doc.snippetNeedle),
307
- contentLength: Number(doc.contentLength) || 0,
308
- hideFromSearch: parseDocumentHideFromSearch(doc.hideFromSearch),
309
- updatedAt: doc.updatedAt,
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,
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: improved
3
+ date: 2026-09-21
4
+ ---
5
+
6
+ Search now ranks exact and partial title matches above incidental matches in document bodies.