@gmickel/gno 0.15.1 → 0.17.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 (38) hide show
  1. package/README.md +36 -1
  2. package/package.json +7 -4
  3. package/src/cli/commands/ask.ts +9 -0
  4. package/src/cli/commands/query.ts +3 -2
  5. package/src/cli/pager.ts +1 -1
  6. package/src/cli/program.ts +89 -0
  7. package/src/core/links.ts +92 -20
  8. package/src/ingestion/sync.ts +267 -23
  9. package/src/ingestion/types.ts +2 -0
  10. package/src/ingestion/walker.ts +2 -1
  11. package/src/llm/nodeLlamaCpp/embedding.ts +53 -10
  12. package/src/mcp/tools/index.ts +30 -1
  13. package/src/mcp/tools/query.ts +22 -2
  14. package/src/mcp/tools/search.ts +8 -0
  15. package/src/mcp/tools/vsearch.ts +8 -0
  16. package/src/pipeline/answer.ts +324 -7
  17. package/src/pipeline/expansion.ts +243 -7
  18. package/src/pipeline/explain.ts +93 -5
  19. package/src/pipeline/hybrid.ts +240 -57
  20. package/src/pipeline/query-modes.ts +125 -0
  21. package/src/pipeline/rerank.ts +34 -13
  22. package/src/pipeline/search.ts +41 -3
  23. package/src/pipeline/temporal.ts +257 -0
  24. package/src/pipeline/types.ts +58 -0
  25. package/src/pipeline/vsearch.ts +107 -9
  26. package/src/serve/public/app.tsx +1 -3
  27. package/src/serve/public/globals.built.css +2 -2
  28. package/src/serve/public/lib/retrieval-filters.ts +167 -0
  29. package/src/serve/public/pages/Ask.tsx +339 -109
  30. package/src/serve/public/pages/Browse.tsx +71 -5
  31. package/src/serve/public/pages/DocView.tsx +2 -21
  32. package/src/serve/public/pages/Search.tsx +507 -120
  33. package/src/serve/routes/api.ts +202 -2
  34. package/src/store/migrations/006-document-metadata.ts +104 -0
  35. package/src/store/migrations/007-document-date-fields.ts +24 -0
  36. package/src/store/migrations/index.ts +3 -1
  37. package/src/store/sqlite/adapter.ts +218 -5
  38. package/src/store/types.ts +46 -0
package/README.md CHANGED
@@ -139,16 +139,38 @@ gno skill install --target all # Both Claude + Codex
139
139
 
140
140
  **BM25** indexes full documents (not chunks) with Snowball stemming, so "running" matches "run".
141
141
  **Vector** embeds chunks with document titles for context awareness.
142
+ All retrieval modes also support metadata filters: `--since`, `--until`, `--category`, `--author`, `--tags-all`, `--tags-any`.
142
143
 
143
144
  ```bash
144
145
  gno search "handleAuth" # Find exact matches
145
146
  gno vsearch "error handling patterns" # Semantic similarity
146
147
  gno query "database optimization" # Full pipeline
148
+ gno query "meeting decisions" --since "last month" --category "meeting,notes" --author "gordon"
147
149
  gno ask "what did we decide" --answer # AI synthesis
148
150
  ```
149
151
 
150
152
  Output formats: `--json`, `--files`, `--csv`, `--md`, `--xml`
151
153
 
154
+ ### Retrieval V2 Controls
155
+
156
+ Existing query calls still work. Retrieval v2 adds optional structured intent control and deeper explain output.
157
+
158
+ ```bash
159
+ # Existing call (unchanged)
160
+ gno query "auth flow" --thorough
161
+
162
+ # Structured retrieval intent
163
+ gno query "auth flow" \
164
+ --query-mode term:"jwt refresh token -oauth1" \
165
+ --query-mode intent:"how refresh token rotation works" \
166
+ --query-mode hyde:"Refresh tokens rotate on each use and previous tokens are revoked." \
167
+ --explain
168
+ ```
169
+
170
+ - Modes: `term` (BM25-focused), `intent` (semantic-focused), `hyde` (single hypothetical passage)
171
+ - Explain includes stage timings, fallback/cache counters, and per-result score components
172
+ - `gno ask --json` includes `meta.answerContext` for adaptive source selection traces
173
+
152
174
  ---
153
175
 
154
176
  ## Agent Integration
@@ -372,7 +394,7 @@ graph TD
372
394
 
373
395
  ## Local Models
374
396
 
375
- Models auto-download on first use to `~/.cache/gno/models/`. Alternatively, offload to a GPU server on your network using HTTP backends.
397
+ 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
398
 
377
399
  | Model | Purpose | Size |
378
400
  | :------------------ | :------------------------------------ | :----------- |
@@ -444,6 +466,19 @@ bun run lint && bun run typecheck
444
466
 
445
467
  > **Contributing**: [CONTRIBUTING.md](.github/CONTRIBUTING.md)
446
468
 
469
+ ### Evals and Benchmark Deltas
470
+
471
+ Use retrieval benchmark commands to track quality and latency over time:
472
+
473
+ ```bash
474
+ bun run eval:hybrid
475
+ bun run eval:hybrid:baseline
476
+ bun run eval:hybrid:delta
477
+ ```
478
+
479
+ - Benchmark guide: [evals/README.md](./evals/README.md)
480
+ - Latest baseline snapshot: [evals/fixtures/hybrid-baseline/latest.json](./evals/fixtures/hybrid-baseline/latest.json)
481
+
447
482
  ---
448
483
 
449
484
  ## License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gmickel/gno",
3
- "version": "0.15.1",
3
+ "version": "0.17.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",
@@ -129,13 +132,13 @@
129
132
  "evalite": "^1.0.0-beta.15",
130
133
  "exceljs": "^4.4.0",
131
134
  "lefthook": "^2.0.13",
132
- "oxfmt": "^0.27.0",
133
- "oxlint": "^1.36.0",
135
+ "oxfmt": "^0.28.0",
136
+ "oxlint": "^1.42.0",
134
137
  "oxlint-tsgolint": "^0.11.4",
135
138
  "pdf-lib": "^1.17.1",
136
139
  "playwright": "^1.52.0",
137
140
  "pptxgenjs": "^4.0.1",
138
- "ultracite": "7.1.1",
141
+ "ultracite": "7.1.3",
139
142
  "vitest": "^4.0.16"
140
143
  },
141
144
  "peerDependencies": {
@@ -192,6 +192,12 @@ export async function ask(
192
192
  limit,
193
193
  collection: options.collection,
194
194
  lang: options.lang,
195
+ since: options.since,
196
+ until: options.until,
197
+ categories: options.categories,
198
+ author: options.author,
199
+ tagsAll: options.tagsAll,
200
+ tagsAny: options.tagsAny,
195
201
  noExpand: options.noExpand,
196
202
  noRerank: options.noRerank,
197
203
  });
@@ -205,6 +211,7 @@ export async function ask(
205
211
  // Generate grounded answer if requested
206
212
  let answer: string | undefined;
207
213
  let citations: Citation[] | undefined;
214
+ let answerContext: AskResult["meta"]["answerContext"] | undefined;
208
215
  let answerGenerated = false;
209
216
 
210
217
  // Only generate answer if:
@@ -236,6 +243,7 @@ export async function ask(
236
243
  const processed = processAnswerResult(rawResult);
237
244
  answer = processed.answer;
238
245
  citations = processed.citations;
246
+ answerContext = processed.answerContext;
239
247
  answerGenerated = true;
240
248
  }
241
249
 
@@ -252,6 +260,7 @@ export async function ask(
252
260
  vectorsUsed: searchResult.value.meta.vectorsUsed ?? false,
253
261
  answerGenerated,
254
262
  totalResults: results.length,
263
+ answerContext,
255
264
  },
256
265
  };
257
266
 
@@ -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
- await 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,16 @@ 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)")
206
227
  .option("--tags-all <tags>", "require ALL tags (comma-separated)")
207
228
  .option("--tags-any <tags>", "require ANY tag (comma-separated)")
208
229
  .option("--full", "include full content")
@@ -247,6 +268,7 @@ function wireSearchCommands(program: Command): void {
247
268
  const limit = cmdOpts.limit
248
269
  ? parsePositiveInt("limit", cmdOpts.limit)
249
270
  : getDefaultLimit(format);
271
+ const categories = parseCsvValues(cmdOpts.category);
250
272
 
251
273
  const { search, formatSearch } = await import("./commands/search");
252
274
  const result = await search(queryText, {
@@ -254,6 +276,10 @@ function wireSearchCommands(program: Command): void {
254
276
  minScore,
255
277
  collection: cmdOpts.collection as string | undefined,
256
278
  lang: cmdOpts.lang as string | undefined,
279
+ since: cmdOpts.since as string | undefined,
280
+ until: cmdOpts.until as string | undefined,
281
+ categories,
282
+ author: cmdOpts.author as string | undefined,
257
283
  tagsAll,
258
284
  tagsAny,
259
285
  full: Boolean(cmdOpts.full),
@@ -293,6 +319,16 @@ function wireSearchCommands(program: Command): void {
293
319
  .option("--min-score <num>", "minimum score threshold")
294
320
  .option("-c, --collection <name>", "filter by collection")
295
321
  .option("--lang <code>", "language filter/hint (BCP-47)")
322
+ .option(
323
+ "--since <date>",
324
+ "modified-at lower bound (ISO date/time or token)"
325
+ )
326
+ .option(
327
+ "--until <date>",
328
+ "modified-at upper bound (ISO date/time or token)"
329
+ )
330
+ .option("--category <values>", "require category match (comma-separated)")
331
+ .option("--author <text>", "filter by author (case-insensitive contains)")
296
332
  .option("--tags-all <tags>", "require ALL tags (comma-separated)")
297
333
  .option("--tags-any <tags>", "require ANY tag (comma-separated)")
298
334
  .option("--full", "include full content")
@@ -337,6 +373,7 @@ function wireSearchCommands(program: Command): void {
337
373
  const limit = cmdOpts.limit
338
374
  ? parsePositiveInt("limit", cmdOpts.limit)
339
375
  : getDefaultLimit(format);
376
+ const categories = parseCsvValues(cmdOpts.category);
340
377
 
341
378
  const { vsearch, formatVsearch } = await import("./commands/vsearch");
342
379
  const result = await vsearch(queryText, {
@@ -344,6 +381,10 @@ function wireSearchCommands(program: Command): void {
344
381
  minScore,
345
382
  collection: cmdOpts.collection as string | undefined,
346
383
  lang: cmdOpts.lang as string | undefined,
384
+ since: cmdOpts.since as string | undefined,
385
+ until: cmdOpts.until as string | undefined,
386
+ categories,
387
+ author: cmdOpts.author as string | undefined,
347
388
  tagsAll,
348
389
  tagsAny,
349
390
  full: Boolean(cmdOpts.full),
@@ -378,6 +419,16 @@ function wireSearchCommands(program: Command): void {
378
419
  .option("--min-score <num>", "minimum score threshold")
379
420
  .option("-c, --collection <name>", "filter by collection")
380
421
  .option("--lang <code>", "language hint (BCP-47)")
422
+ .option(
423
+ "--since <date>",
424
+ "modified-at lower bound (ISO date/time or token)"
425
+ )
426
+ .option(
427
+ "--until <date>",
428
+ "modified-at upper bound (ISO date/time or token)"
429
+ )
430
+ .option("--category <values>", "require category match (comma-separated)")
431
+ .option("--author <text>", "filter by author (case-insensitive contains)")
381
432
  .option("--tags-all <tags>", "require ALL tags (comma-separated)")
382
433
  .option("--tags-any <tags>", "require ANY tag (comma-separated)")
383
434
  .option("--full", "include full content")
@@ -386,6 +437,12 @@ function wireSearchCommands(program: Command): void {
386
437
  .option("--thorough", "enable query expansion (slower, ~5-8s)")
387
438
  .option("--no-expand", "disable query expansion")
388
439
  .option("--no-rerank", "disable reranking")
440
+ .option(
441
+ "--query-mode <mode:text>",
442
+ "structured mode entry (repeatable): term:<text>, intent:<text>, or hyde:<text>",
443
+ (value: string, previous: string[] = []) => [...previous, value],
444
+ []
445
+ )
389
446
  .option("--explain", "include scoring explanation")
390
447
  .option("--json", "JSON output")
391
448
  .option("--md", "Markdown output")
@@ -407,6 +464,17 @@ function wireSearchCommands(program: Command): void {
407
464
  throw new CliError("VALIDATION", "--min-score must be between 0 and 1");
408
465
  }
409
466
 
467
+ // Parse optional structured query modes
468
+ let queryModes: import("../pipeline/types").QueryModeInput[] | undefined;
469
+ if (Array.isArray(cmdOpts.queryMode) && cmdOpts.queryMode.length > 0) {
470
+ const { parseQueryModeSpecs } = await import("../pipeline/query-modes");
471
+ const parsed = parseQueryModeSpecs(cmdOpts.queryMode as string[]);
472
+ if (!parsed.ok) {
473
+ throw new CliError("VALIDATION", parsed.error.message);
474
+ }
475
+ queryModes = parsed.value;
476
+ }
477
+
410
478
  // Parse and validate tag filters
411
479
  let tagsAll: string[] | undefined;
412
480
  let tagsAny: string[] | undefined;
@@ -427,6 +495,7 @@ function wireSearchCommands(program: Command): void {
427
495
  const limit = cmdOpts.limit
428
496
  ? parsePositiveInt("limit", cmdOpts.limit)
429
497
  : getDefaultLimit(format);
498
+ const categories = parseCsvValues(cmdOpts.category);
430
499
 
431
500
  // Determine expansion/rerank settings based on flags
432
501
  // Priority: --fast > --thorough > --no-expand/--no-rerank > default
@@ -458,12 +527,17 @@ function wireSearchCommands(program: Command): void {
458
527
  minScore,
459
528
  collection: cmdOpts.collection as string | undefined,
460
529
  lang: cmdOpts.lang as string | undefined,
530
+ since: cmdOpts.since as string | undefined,
531
+ until: cmdOpts.until as string | undefined,
532
+ categories,
533
+ author: cmdOpts.author as string | undefined,
461
534
  tagsAll,
462
535
  tagsAny,
463
536
  full: Boolean(cmdOpts.full),
464
537
  lineNumbers: Boolean(cmdOpts.lineNumbers),
465
538
  noExpand,
466
539
  noRerank,
540
+ queryModes,
467
541
  explain: Boolean(cmdOpts.explain),
468
542
  json: format === "json",
469
543
  md: format === "md",
@@ -490,6 +564,16 @@ function wireSearchCommands(program: Command): void {
490
564
  .option("-n, --limit <num>", "max source results")
491
565
  .option("-c, --collection <name>", "filter by collection")
492
566
  .option("--lang <code>", "language hint (BCP-47)")
567
+ .option(
568
+ "--since <date>",
569
+ "modified-at lower bound (ISO date/time or token)"
570
+ )
571
+ .option(
572
+ "--until <date>",
573
+ "modified-at upper bound (ISO date/time or token)"
574
+ )
575
+ .option("--category <values>", "require category match (comma-separated)")
576
+ .option("--author <text>", "filter by author (case-insensitive contains)")
493
577
  .option("--fast", "skip expansion and reranking (fastest)")
494
578
  .option("--thorough", "enable query expansion (slower)")
495
579
  .option("--answer", "generate short grounded answer")
@@ -515,6 +599,7 @@ function wireSearchCommands(program: Command): void {
515
599
  const maxAnswerTokens = cmdOpts.maxAnswerTokens
516
600
  ? parsePositiveInt("max-answer-tokens", cmdOpts.maxAnswerTokens)
517
601
  : undefined;
602
+ const categories = parseCsvValues(cmdOpts.category);
518
603
 
519
604
  // Determine expansion/rerank settings based on flags
520
605
  // Default: skip expansion (balanced mode)
@@ -535,6 +620,10 @@ function wireSearchCommands(program: Command): void {
535
620
  limit,
536
621
  collection: cmdOpts.collection as string | undefined,
537
622
  lang: cmdOpts.lang as string | undefined,
623
+ since: cmdOpts.since as string | undefined,
624
+ until: cmdOpts.until as string | undefined,
625
+ categories,
626
+ author: cmdOpts.author as string | undefined,
538
627
  noExpand,
539
628
  noRerank,
540
629
  // Per spec: --answer defaults to false, --no-answer forces retrieval-only
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;