@gmickel/gno 0.16.0 → 0.18.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 (43) hide show
  1. package/README.md +55 -2
  2. package/package.json +4 -1
  3. package/src/cli/commands/ask.ts +13 -0
  4. package/src/cli/commands/models/use.ts +1 -0
  5. package/src/cli/commands/query.ts +3 -2
  6. package/src/cli/pager.ts +1 -1
  7. package/src/cli/program.ts +107 -0
  8. package/src/config/types.ts +2 -0
  9. package/src/core/links.ts +92 -20
  10. package/src/ingestion/sync.ts +267 -23
  11. package/src/ingestion/types.ts +2 -0
  12. package/src/ingestion/walker.ts +2 -1
  13. package/src/llm/nodeLlamaCpp/generation.ts +3 -1
  14. package/src/llm/registry.ts +1 -0
  15. package/src/llm/types.ts +2 -0
  16. package/src/mcp/tools/index.ts +34 -1
  17. package/src/mcp/tools/query.ts +26 -2
  18. package/src/mcp/tools/search.ts +10 -0
  19. package/src/mcp/tools/vsearch.ts +10 -0
  20. package/src/pipeline/answer.ts +324 -7
  21. package/src/pipeline/expansion.ts +282 -11
  22. package/src/pipeline/explain.ts +93 -5
  23. package/src/pipeline/hybrid.ts +273 -70
  24. package/src/pipeline/intent.ts +152 -0
  25. package/src/pipeline/query-modes.ts +125 -0
  26. package/src/pipeline/rerank.ts +109 -51
  27. package/src/pipeline/search.ts +58 -4
  28. package/src/pipeline/temporal.ts +257 -0
  29. package/src/pipeline/types.ts +67 -0
  30. package/src/pipeline/vsearch.ts +121 -10
  31. package/src/serve/public/app.tsx +1 -3
  32. package/src/serve/public/globals.built.css +2 -2
  33. package/src/serve/public/lib/retrieval-filters.ts +174 -0
  34. package/src/serve/public/pages/Ask.tsx +378 -109
  35. package/src/serve/public/pages/Browse.tsx +71 -5
  36. package/src/serve/public/pages/DocView.tsx +2 -21
  37. package/src/serve/public/pages/Search.tsx +561 -120
  38. package/src/serve/routes/api.ts +247 -2
  39. package/src/store/migrations/006-document-metadata.ts +104 -0
  40. package/src/store/migrations/007-document-date-fields.ts +24 -0
  41. package/src/store/migrations/index.ts +3 -1
  42. package/src/store/sqlite/adapter.ts +218 -5
  43. package/src/store/types.ts +46 -0
package/README.md CHANGED
@@ -32,7 +32,22 @@ GNO is a local knowledge engine that turns your documents into a searchable, con
32
32
 
33
33
  ---
34
34
 
35
- ## What's New in v0.15
35
+ ## What's New in v0.18
36
+
37
+ - **Intent Steering**: optional `intent` control for ambiguous queries across CLI, API, Web, and MCP query flows
38
+ - **Rerank Controls**: `candidateLimit` lets you tune rerank cost vs. recall on slower or memory-constrained machines
39
+ - **Stability**: query expansion now uses a bounded configurable context size (`models.expandContextSize`, default `2048`)
40
+ - **Rerank Efficiency**: identical chunk texts are deduplicated before scoring and expanded back out deterministically
41
+
42
+ ### v0.17
43
+
44
+ - **Structured Query Modes**: `term`, `intent`, and `hyde` controls across CLI, API, MCP, and Web
45
+ - **Temporal Retrieval Upgrades**: `since`/`until`, date-range parsing, and recency sorting with frontmatter-date fallback
46
+ - **Web Retrieval UX Polish**: richer advanced controls in Search and Ask (collection/date/category/author/tags + query modes)
47
+ - **Metadata-Aware Retrieval**: ingestion now materializes document metadata/date fields for better filtering and ranking
48
+ - **Migration Reliability**: SQLite-compatible migration path for existing indexes (including older SQLite engines)
49
+
50
+ ### v0.15
36
51
 
37
52
  - **HTTP Backends**: Offload embedding, reranking, and generation to remote GPU servers
38
53
  - Simple URI config: `http://host:port/path#modelname`
@@ -139,16 +154,41 @@ gno skill install --target all # Both Claude + Codex
139
154
 
140
155
  **BM25** indexes full documents (not chunks) with Snowball stemming, so "running" matches "run".
141
156
  **Vector** embeds chunks with document titles for context awareness.
157
+ All retrieval modes also support metadata filters: `--since`, `--until`, `--category`, `--author`, `--tags-all`, `--tags-any`.
142
158
 
143
159
  ```bash
144
160
  gno search "handleAuth" # Find exact matches
145
161
  gno vsearch "error handling patterns" # Semantic similarity
146
162
  gno query "database optimization" # Full pipeline
163
+ gno query "meeting decisions" --since "last month" --category "meeting,notes" --author "gordon"
164
+ gno query "performance" --intent "web performance and latency"
147
165
  gno ask "what did we decide" --answer # AI synthesis
148
166
  ```
149
167
 
150
168
  Output formats: `--json`, `--files`, `--csv`, `--md`, `--xml`
151
169
 
170
+ ### Retrieval V2 Controls
171
+
172
+ Existing query calls still work. Retrieval v2 adds optional structured intent control and deeper explain output.
173
+
174
+ ```bash
175
+ # Existing call (unchanged)
176
+ gno query "auth flow" --thorough
177
+
178
+ # Structured retrieval intent
179
+ gno query "auth flow" \
180
+ --intent "web authentication and token lifecycle" \
181
+ --candidate-limit 12 \
182
+ --query-mode term:"jwt refresh token -oauth1" \
183
+ --query-mode intent:"how refresh token rotation works" \
184
+ --query-mode hyde:"Refresh tokens rotate on each use and previous tokens are revoked." \
185
+ --explain
186
+ ```
187
+
188
+ - Modes: `term` (BM25-focused), `intent` (semantic-focused), `hyde` (single hypothetical passage)
189
+ - Explain includes stage timings, fallback/cache counters, and per-result score components
190
+ - `gno ask --json` includes `meta.answerContext` for adaptive source selection traces
191
+
152
192
  ---
153
193
 
154
194
  ## Agent Integration
@@ -372,7 +412,7 @@ graph TD
372
412
 
373
413
  ## Local Models
374
414
 
375
- Models auto-download on first use to `~/.cache/gno/models/`. Alternatively, offload to a GPU server on your network using HTTP backends.
415
+ Models auto-download on first use to `~/.cache/gno/models/`. For deterministic startup, set `GNO_NO_AUTO_DOWNLOAD=1` and use `gno models pull` explicitly. Alternatively, offload to a GPU server on your network using HTTP backends.
376
416
 
377
417
  | Model | Purpose | Size |
378
418
  | :------------------ | :------------------------------------ | :----------- |
@@ -444,6 +484,19 @@ bun run lint && bun run typecheck
444
484
 
445
485
  > **Contributing**: [CONTRIBUTING.md](.github/CONTRIBUTING.md)
446
486
 
487
+ ### Evals and Benchmark Deltas
488
+
489
+ Use retrieval benchmark commands to track quality and latency over time:
490
+
491
+ ```bash
492
+ bun run eval:hybrid
493
+ bun run eval:hybrid:baseline
494
+ bun run eval:hybrid:delta
495
+ ```
496
+
497
+ - Benchmark guide: [evals/README.md](./evals/README.md)
498
+ - Latest baseline snapshot: [evals/fixtures/hybrid-baseline/latest.json](./evals/fixtures/hybrid-baseline/latest.json)
499
+
447
500
  ---
448
501
 
449
502
  ## License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gmickel/gno",
3
- "version": "0.16.0",
3
+ "version": "0.18.0",
4
4
  "description": "Local semantic search for your documents. Index Markdown, PDF, and Office files with hybrid BM25 + vector search.",
5
5
  "keywords": [
6
6
  "embeddings",
@@ -51,6 +51,9 @@
51
51
  "test:fixtures": "bun scripts/generate-test-fixtures.ts",
52
52
  "evals": "bun scripts/update-eval-scores.ts",
53
53
  "eval": "bun --bun evalite",
54
+ "eval:hybrid": "bun --bun evalite evals/hybrid.eval.ts",
55
+ "eval:hybrid:baseline": "bun scripts/hybrid-benchmark.ts --write",
56
+ "eval:hybrid:delta": "bun scripts/hybrid-benchmark.ts --delta",
54
57
  "eval:watch": "bun --bun evalite watch",
55
58
  "reset": "bun run src/index.ts reset --confirm",
56
59
  "docs:verify": "bun run scripts/docs-verify.ts",
@@ -192,8 +192,16 @@ export async function ask(
192
192
  limit,
193
193
  collection: options.collection,
194
194
  lang: options.lang,
195
+ intent: options.intent,
196
+ since: options.since,
197
+ until: options.until,
198
+ categories: options.categories,
199
+ author: options.author,
200
+ tagsAll: options.tagsAll,
201
+ tagsAny: options.tagsAny,
195
202
  noExpand: options.noExpand,
196
203
  noRerank: options.noRerank,
204
+ candidateLimit: options.candidateLimit,
197
205
  });
198
206
 
199
207
  if (!searchResult.ok) {
@@ -205,6 +213,7 @@ export async function ask(
205
213
  // Generate grounded answer if requested
206
214
  let answer: string | undefined;
207
215
  let citations: Citation[] | undefined;
216
+ let answerContext: AskResult["meta"]["answerContext"] | undefined;
208
217
  let answerGenerated = false;
209
218
 
210
219
  // Only generate answer if:
@@ -236,6 +245,7 @@ export async function ask(
236
245
  const processed = processAnswerResult(rawResult);
237
246
  answer = processed.answer;
238
247
  citations = processed.citations;
248
+ answerContext = processed.answerContext;
239
249
  answerGenerated = true;
240
250
  }
241
251
 
@@ -250,8 +260,11 @@ export async function ask(
250
260
  expanded: searchResult.value.meta.expanded ?? false,
251
261
  reranked: searchResult.value.meta.reranked ?? false,
252
262
  vectorsUsed: searchResult.value.meta.vectorsUsed ?? false,
263
+ intent: searchResult.value.meta.intent,
264
+ candidateLimit: searchResult.value.meta.candidateLimit,
253
265
  answerGenerated,
254
266
  totalResults: results.length,
267
+ answerContext,
255
268
  },
256
269
  };
257
270
 
@@ -58,6 +58,7 @@ export async function modelsUse(
58
58
  presets: config.models?.presets ?? [],
59
59
  loadTimeout: config.models?.loadTimeout ?? 60_000,
60
60
  inferenceTimeout: config.models?.inferenceTimeout ?? 30_000,
61
+ expandContextSize: config.models?.expandContextSize ?? 2_048,
61
62
  warmModelTtl: config.models?.warmModelTtl ?? 300_000,
62
63
  },
63
64
  };
@@ -121,8 +121,9 @@ export async function query(
121
121
  embedPort = embedResult.value;
122
122
  }
123
123
 
124
- // Create generation port (for expansion) - optional
125
- if (!options.noExpand) {
124
+ // Create generation port (for expansion) - optional.
125
+ // Skip when structured query modes are provided.
126
+ if (!options.noExpand && !options.queryModes?.length) {
126
127
  const genUri = options.genModel ?? preset.gen;
127
128
  const genResult = await llm.createGenerationPort(genUri, {
128
129
  policy,
package/src/cli/pager.ts CHANGED
@@ -167,7 +167,7 @@ export class Pager {
167
167
 
168
168
  // Write content to pager stdin
169
169
  if (proc.stdin) {
170
- proc.stdin.write(content + "\n");
170
+ void proc.stdin.write(content + "\n");
171
171
  await proc.stdin.end();
172
172
  }
173
173
 
@@ -130,6 +130,17 @@ async function writeOutput(
130
130
  }
131
131
  }
132
132
 
133
+ function parseCsvValues(raw: unknown): string[] | undefined {
134
+ if (typeof raw !== "string") {
135
+ return undefined;
136
+ }
137
+ const values = raw
138
+ .split(",")
139
+ .map((v) => v.trim().toLowerCase())
140
+ .filter((v) => v.length > 0);
141
+ return values.length > 0 ? values : undefined;
142
+ }
143
+
133
144
  // ─────────────────────────────────────────────────────────────────────────────
134
145
  // Program Factory
135
146
  // ─────────────────────────────────────────────────────────────────────────────
@@ -203,6 +214,17 @@ function wireSearchCommands(program: Command): void {
203
214
  .option("--min-score <num>", "minimum score threshold")
204
215
  .option("-c, --collection <name>", "filter by collection")
205
216
  .option("--lang <code>", "language filter/hint (BCP-47)")
217
+ .option(
218
+ "--since <date>",
219
+ "modified-at lower bound (ISO date/time or token)"
220
+ )
221
+ .option(
222
+ "--until <date>",
223
+ "modified-at upper bound (ISO date/time or token)"
224
+ )
225
+ .option("--category <values>", "require category match (comma-separated)")
226
+ .option("--author <text>", "filter by author (case-insensitive contains)")
227
+ .option("--intent <text>", "disambiguating context for ambiguous queries")
206
228
  .option("--tags-all <tags>", "require ALL tags (comma-separated)")
207
229
  .option("--tags-any <tags>", "require ANY tag (comma-separated)")
208
230
  .option("--full", "include full content")
@@ -247,6 +269,7 @@ function wireSearchCommands(program: Command): void {
247
269
  const limit = cmdOpts.limit
248
270
  ? parsePositiveInt("limit", cmdOpts.limit)
249
271
  : getDefaultLimit(format);
272
+ const categories = parseCsvValues(cmdOpts.category);
250
273
 
251
274
  const { search, formatSearch } = await import("./commands/search");
252
275
  const result = await search(queryText, {
@@ -254,6 +277,11 @@ function wireSearchCommands(program: Command): void {
254
277
  minScore,
255
278
  collection: cmdOpts.collection as string | undefined,
256
279
  lang: cmdOpts.lang as string | undefined,
280
+ since: cmdOpts.since as string | undefined,
281
+ until: cmdOpts.until as string | undefined,
282
+ categories,
283
+ author: cmdOpts.author as string | undefined,
284
+ intent: cmdOpts.intent as string | undefined,
257
285
  tagsAll,
258
286
  tagsAny,
259
287
  full: Boolean(cmdOpts.full),
@@ -293,6 +321,17 @@ function wireSearchCommands(program: Command): void {
293
321
  .option("--min-score <num>", "minimum score threshold")
294
322
  .option("-c, --collection <name>", "filter by collection")
295
323
  .option("--lang <code>", "language filter/hint (BCP-47)")
324
+ .option(
325
+ "--since <date>",
326
+ "modified-at lower bound (ISO date/time or token)"
327
+ )
328
+ .option(
329
+ "--until <date>",
330
+ "modified-at upper bound (ISO date/time or token)"
331
+ )
332
+ .option("--category <values>", "require category match (comma-separated)")
333
+ .option("--author <text>", "filter by author (case-insensitive contains)")
334
+ .option("--intent <text>", "disambiguating context for ambiguous queries")
296
335
  .option("--tags-all <tags>", "require ALL tags (comma-separated)")
297
336
  .option("--tags-any <tags>", "require ANY tag (comma-separated)")
298
337
  .option("--full", "include full content")
@@ -337,6 +376,7 @@ function wireSearchCommands(program: Command): void {
337
376
  const limit = cmdOpts.limit
338
377
  ? parsePositiveInt("limit", cmdOpts.limit)
339
378
  : getDefaultLimit(format);
379
+ const categories = parseCsvValues(cmdOpts.category);
340
380
 
341
381
  const { vsearch, formatVsearch } = await import("./commands/vsearch");
342
382
  const result = await vsearch(queryText, {
@@ -344,6 +384,11 @@ function wireSearchCommands(program: Command): void {
344
384
  minScore,
345
385
  collection: cmdOpts.collection as string | undefined,
346
386
  lang: cmdOpts.lang as string | undefined,
387
+ since: cmdOpts.since as string | undefined,
388
+ until: cmdOpts.until as string | undefined,
389
+ categories,
390
+ author: cmdOpts.author as string | undefined,
391
+ intent: cmdOpts.intent as string | undefined,
347
392
  tagsAll,
348
393
  tagsAny,
349
394
  full: Boolean(cmdOpts.full),
@@ -378,6 +423,17 @@ function wireSearchCommands(program: Command): void {
378
423
  .option("--min-score <num>", "minimum score threshold")
379
424
  .option("-c, --collection <name>", "filter by collection")
380
425
  .option("--lang <code>", "language hint (BCP-47)")
426
+ .option(
427
+ "--since <date>",
428
+ "modified-at lower bound (ISO date/time or token)"
429
+ )
430
+ .option(
431
+ "--until <date>",
432
+ "modified-at upper bound (ISO date/time or token)"
433
+ )
434
+ .option("--category <values>", "require category match (comma-separated)")
435
+ .option("--author <text>", "filter by author (case-insensitive contains)")
436
+ .option("--intent <text>", "disambiguating context for ambiguous queries")
381
437
  .option("--tags-all <tags>", "require ALL tags (comma-separated)")
382
438
  .option("--tags-any <tags>", "require ANY tag (comma-separated)")
383
439
  .option("--full", "include full content")
@@ -386,6 +442,13 @@ function wireSearchCommands(program: Command): void {
386
442
  .option("--thorough", "enable query expansion (slower, ~5-8s)")
387
443
  .option("--no-expand", "disable query expansion")
388
444
  .option("--no-rerank", "disable reranking")
445
+ .option(
446
+ "--query-mode <mode:text>",
447
+ "structured mode entry (repeatable): term:<text>, intent:<text>, or hyde:<text>",
448
+ (value: string, previous: string[] = []) => [...previous, value],
449
+ []
450
+ )
451
+ .option("-C, --candidate-limit <num>", "max candidates passed to reranking")
389
452
  .option("--explain", "include scoring explanation")
390
453
  .option("--json", "JSON output")
391
454
  .option("--md", "Markdown output")
@@ -407,6 +470,17 @@ function wireSearchCommands(program: Command): void {
407
470
  throw new CliError("VALIDATION", "--min-score must be between 0 and 1");
408
471
  }
409
472
 
473
+ // Parse optional structured query modes
474
+ let queryModes: import("../pipeline/types").QueryModeInput[] | undefined;
475
+ if (Array.isArray(cmdOpts.queryMode) && cmdOpts.queryMode.length > 0) {
476
+ const { parseQueryModeSpecs } = await import("../pipeline/query-modes");
477
+ const parsed = parseQueryModeSpecs(cmdOpts.queryMode as string[]);
478
+ if (!parsed.ok) {
479
+ throw new CliError("VALIDATION", parsed.error.message);
480
+ }
481
+ queryModes = parsed.value;
482
+ }
483
+
410
484
  // Parse and validate tag filters
411
485
  let tagsAll: string[] | undefined;
412
486
  let tagsAny: string[] | undefined;
@@ -427,6 +501,10 @@ function wireSearchCommands(program: Command): void {
427
501
  const limit = cmdOpts.limit
428
502
  ? parsePositiveInt("limit", cmdOpts.limit)
429
503
  : getDefaultLimit(format);
504
+ const candidateLimit = cmdOpts.candidateLimit
505
+ ? parsePositiveInt("candidate-limit", cmdOpts.candidateLimit)
506
+ : undefined;
507
+ const categories = parseCsvValues(cmdOpts.category);
430
508
 
431
509
  // Determine expansion/rerank settings based on flags
432
510
  // Priority: --fast > --thorough > --no-expand/--no-rerank > default
@@ -458,12 +536,19 @@ function wireSearchCommands(program: Command): void {
458
536
  minScore,
459
537
  collection: cmdOpts.collection as string | undefined,
460
538
  lang: cmdOpts.lang as string | undefined,
539
+ since: cmdOpts.since as string | undefined,
540
+ until: cmdOpts.until as string | undefined,
541
+ categories,
542
+ author: cmdOpts.author as string | undefined,
543
+ intent: cmdOpts.intent as string | undefined,
461
544
  tagsAll,
462
545
  tagsAny,
463
546
  full: Boolean(cmdOpts.full),
464
547
  lineNumbers: Boolean(cmdOpts.lineNumbers),
465
548
  noExpand,
466
549
  noRerank,
550
+ candidateLimit,
551
+ queryModes,
467
552
  explain: Boolean(cmdOpts.explain),
468
553
  json: format === "json",
469
554
  md: format === "md",
@@ -490,8 +575,20 @@ function wireSearchCommands(program: Command): void {
490
575
  .option("-n, --limit <num>", "max source results")
491
576
  .option("-c, --collection <name>", "filter by collection")
492
577
  .option("--lang <code>", "language hint (BCP-47)")
578
+ .option(
579
+ "--since <date>",
580
+ "modified-at lower bound (ISO date/time or token)"
581
+ )
582
+ .option(
583
+ "--until <date>",
584
+ "modified-at upper bound (ISO date/time or token)"
585
+ )
586
+ .option("--category <values>", "require category match (comma-separated)")
587
+ .option("--author <text>", "filter by author (case-insensitive contains)")
588
+ .option("--intent <text>", "disambiguating context for ambiguous queries")
493
589
  .option("--fast", "skip expansion and reranking (fastest)")
494
590
  .option("--thorough", "enable query expansion (slower)")
591
+ .option("-C, --candidate-limit <num>", "max candidates passed to reranking")
495
592
  .option("--answer", "generate short grounded answer")
496
593
  .option("--no-answer", "force retrieval-only output")
497
594
  .option("--max-answer-tokens <num>", "max answer tokens")
@@ -510,11 +607,15 @@ function wireSearchCommands(program: Command): void {
510
607
  const limit = cmdOpts.limit
511
608
  ? parsePositiveInt("limit", cmdOpts.limit)
512
609
  : getDefaultLimit(format);
610
+ const candidateLimit = cmdOpts.candidateLimit
611
+ ? parsePositiveInt("candidate-limit", cmdOpts.candidateLimit)
612
+ : undefined;
513
613
 
514
614
  // Parse max-answer-tokens (optional, defaults to 512 in command impl)
515
615
  const maxAnswerTokens = cmdOpts.maxAnswerTokens
516
616
  ? parsePositiveInt("max-answer-tokens", cmdOpts.maxAnswerTokens)
517
617
  : undefined;
618
+ const categories = parseCsvValues(cmdOpts.category);
518
619
 
519
620
  // Determine expansion/rerank settings based on flags
520
621
  // Default: skip expansion (balanced mode)
@@ -535,8 +636,14 @@ function wireSearchCommands(program: Command): void {
535
636
  limit,
536
637
  collection: cmdOpts.collection as string | undefined,
537
638
  lang: cmdOpts.lang as string | undefined,
639
+ since: cmdOpts.since as string | undefined,
640
+ until: cmdOpts.until as string | undefined,
641
+ categories,
642
+ author: cmdOpts.author as string | undefined,
643
+ intent: cmdOpts.intent as string | undefined,
538
644
  noExpand,
539
645
  noRerank,
646
+ candidateLimit,
540
647
  // Per spec: --answer defaults to false, --no-answer forces retrieval-only
541
648
  // Commander creates separate cmdOpts.noAnswer for --no-answer flag
542
649
  answer: Boolean(cmdOpts.answer),
@@ -209,6 +209,8 @@ export const ModelConfigSchema = z.object({
209
209
  loadTimeout: z.number().default(60_000),
210
210
  /** Inference timeout in ms */
211
211
  inferenceTimeout: z.number().default(30_000),
212
+ /** Context size used for query expansion generation */
213
+ expandContextSize: z.number().int().min(256).default(2_048),
212
214
  /** Keep warm model TTL in ms (5 min) */
213
215
  warmModelTtl: z.number().default(300_000),
214
216
  });
package/src/core/links.ts CHANGED
@@ -80,6 +80,10 @@ const UNSAFE_PERCENT_CODES = new Set(["%2F", "%5C", "%00", "%2f", "%5c"]);
80
80
  */
81
81
  const WIKI_LINK_REGEX = /\[\[([^\]|]+(?:\|[^\]]+)?)\]\]/g;
82
82
 
83
+ /** Logseq embed: {{embed [[Page]]}} or {{embed ((block-id))}} */
84
+ const LOGSEQ_EMBED_REGEX =
85
+ /\{\{\s*embed\s+(\[\[[^\]]+\]\]|\(\([^)]+\)\))\s*\}\}/gi;
86
+
83
87
  /**
84
88
  * Markdown inline link: [text](url)
85
89
  * Captures: 1=text, 2=url (path and optional anchor)
@@ -273,6 +277,43 @@ export function parseLinks(
273
277
  ): ParsedLink[] {
274
278
  const links: ParsedLink[] = [];
275
279
 
280
+ const pushWikiLink = (
281
+ raw: string,
282
+ target: string,
283
+ startOffset: number,
284
+ endOffset: number,
285
+ displayText?: string
286
+ ): void => {
287
+ const trimmedTarget = target.trim();
288
+ const hasScheme =
289
+ /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmedTarget) ||
290
+ trimmedTarget.startsWith("mailto:");
291
+ if (hasScheme || trimmedTarget.startsWith("//")) {
292
+ return;
293
+ }
294
+
295
+ const parts = parseTargetParts(trimmedTarget);
296
+ if (!parts.ref) {
297
+ return;
298
+ }
299
+
300
+ const startPos = offsetToPosition(startOffset, lineOffsets);
301
+ const endPos = offsetToPosition(endOffset, lineOffsets);
302
+
303
+ links.push({
304
+ kind: "wiki",
305
+ raw,
306
+ targetRef: parts.ref,
307
+ targetAnchor: parts.anchor,
308
+ targetCollection: parts.collection,
309
+ displayText,
310
+ startLine: startPos.line,
311
+ startCol: startPos.col,
312
+ endLine: endPos.line,
313
+ endCol: endPos.col,
314
+ });
315
+ };
316
+
276
317
  // Parse wiki links
277
318
  WIKI_LINK_REGEX.lastIndex = 0;
278
319
  let match: RegExpExecArray | null;
@@ -281,6 +322,14 @@ export function parseLinks(
281
322
  const startOffset = match.index;
282
323
  const endOffset = startOffset + match[0].length;
283
324
 
325
+ // Skip [[target]] nested in Logseq alias syntax: [Display]([[target]])
326
+ if (
327
+ markdown.slice(Math.max(0, startOffset - 2), startOffset) === "](" &&
328
+ markdown.slice(endOffset, endOffset + 1) === ")"
329
+ ) {
330
+ continue;
331
+ }
332
+
284
333
  // Skip if inside excluded range
285
334
  if (rangeIntersectsExcluded(startOffset, endOffset, excludedRanges)) {
286
335
  continue;
@@ -307,31 +356,35 @@ export function parseLinks(
307
356
  }
308
357
 
309
358
  const trimmedTarget = targetPart.trim();
310
- const hasScheme =
311
- /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmedTarget) ||
312
- trimmedTarget.startsWith("mailto:");
313
- if (hasScheme || trimmedTarget.startsWith("//")) {
359
+ if (!trimmedTarget) {
314
360
  continue;
315
361
  }
362
+ pushWikiLink(match[0], trimmedTarget, startOffset, endOffset, displayText);
363
+ }
316
364
 
317
- const parts = parseTargetParts(trimmedTarget);
318
- if (!parts.ref) continue;
365
+ // Parse Logseq embeds as links
366
+ LOGSEQ_EMBED_REGEX.lastIndex = 0;
319
367
 
320
- const startPos = offsetToPosition(startOffset, lineOffsets);
321
- const endPos = offsetToPosition(endOffset, lineOffsets);
368
+ while ((match = LOGSEQ_EMBED_REGEX.exec(markdown)) !== null) {
369
+ const startOffset = match.index;
370
+ const endOffset = startOffset + match[0].length;
322
371
 
323
- links.push({
324
- kind: "wiki",
325
- raw: match[0],
326
- targetRef: parts.ref,
327
- targetAnchor: parts.anchor,
328
- targetCollection: parts.collection,
329
- displayText,
330
- startLine: startPos.line,
331
- startCol: startPos.col,
332
- endLine: endPos.line,
333
- endCol: endPos.col,
334
- });
372
+ if (rangeIntersectsExcluded(startOffset, endOffset, excludedRanges)) {
373
+ continue;
374
+ }
375
+
376
+ const embedTarget = match[1]?.trim();
377
+ if (!embedTarget) {
378
+ continue;
379
+ }
380
+
381
+ if (embedTarget.startsWith("((") && embedTarget.endsWith("))")) {
382
+ const blockId = embedTarget.slice(2, -2).trim();
383
+ if (blockId.length === 0) {
384
+ continue;
385
+ }
386
+ pushWikiLink(match[0], blockId, startOffset, endOffset);
387
+ }
335
388
  }
336
389
 
337
390
  // Parse markdown links
@@ -350,6 +403,25 @@ export function parseLinks(
350
403
  const url = match[2];
351
404
  if (!url) continue;
352
405
 
406
+ // Logseq alias syntax: [Display]([[Target]])
407
+ if (url.startsWith("[[") && url.endsWith("]]")) {
408
+ const innerTarget = url.slice(2, -2).trim();
409
+ if (innerTarget.length > 0) {
410
+ const displayText =
411
+ linkText && linkText !== innerTarget
412
+ ? truncateText(linkText, MAX_DISPLAY_TEXT_GRAPHEMES)
413
+ : undefined;
414
+ pushWikiLink(
415
+ match[0],
416
+ innerTarget,
417
+ startOffset,
418
+ endOffset,
419
+ displayText
420
+ );
421
+ }
422
+ continue;
423
+ }
424
+
353
425
  // Skip external URLs
354
426
  if (EXTERNAL_URL_REGEX.test(url)) {
355
427
  continue;