@gmickel/gno 2.8.0 → 2.8.1

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 (45) hide show
  1. package/README.md +1 -1
  2. package/assets/skill/SKILL.md +5 -2
  3. package/assets/skill/recipes/memory-scoped-recall.md +10 -5
  4. package/assets/spa-production.json.gz +0 -0
  5. package/browser-extension/artifacts/{gno-browser-clipper-v2.8.0.zip → gno-browser-clipper-v2.8.1.zip} +0 -0
  6. package/browser-extension/artifacts/gno-browser-clipper-v2.8.1.zip.sha256 +1 -0
  7. package/browser-extension/dist/manifest.json +1 -1
  8. package/package.json +1 -1
  9. package/spec/cli.md +41 -6
  10. package/spec/mcp.md +8 -4
  11. package/spec/output-schemas/memory-recall.schema.json +1 -1
  12. package/spec/output-schemas/status.schema.json +3 -3
  13. package/src/cli/commands/graph.ts +3 -1
  14. package/src/cli/commands/links.ts +27 -49
  15. package/src/cli/commands/status.ts +1 -0
  16. package/src/core/audit-links.ts +56 -4
  17. package/src/core/audit-outside-index.ts +215 -0
  18. package/src/core/audit-workspace.ts +13 -7
  19. package/src/core/audit.ts +9 -1
  20. package/src/core/link-inventory-markdown.ts +2 -3
  21. package/src/core/links.ts +40 -17
  22. package/src/core/memory-recall.ts +254 -15
  23. package/src/core/memory-types.ts +12 -0
  24. package/src/core/memory.ts +2 -0
  25. package/src/ingestion/sync.ts +5 -3
  26. package/src/mcp/tools/links.ts +71 -93
  27. package/src/mcp/tools/status.ts +1 -0
  28. package/src/pipeline/search.ts +2 -0
  29. package/src/pipeline/types.ts +4 -0
  30. package/src/sdk/client.ts +1 -0
  31. package/src/serve/public/components/editor/MarkdownPreview.tsx +5 -3
  32. package/src/serve/routes/graph.ts +3 -1
  33. package/src/serve/routes/links.ts +32 -50
  34. package/src/serve/server.ts +2 -1
  35. package/src/serve/status.ts +1 -0
  36. package/src/store/sqlite/adapter.ts +87 -106
  37. package/src/store/sqlite/graph-link-resolver.ts +7 -0
  38. package/src/store/sqlite/graph-similarity.ts +96 -0
  39. package/src/store/sqlite/workspace-link-resolver.ts +119 -31
  40. package/src/store/types.ts +15 -2
  41. package/src/store/vector/status.ts +27 -0
  42. package/src/store/vector/stored-vectors.ts +158 -0
  43. package/src/store/vector/types.ts +6 -0
  44. package/src/store/vector/variant-search.ts +30 -14
  45. package/browser-extension/artifacts/gno-browser-clipper-v2.8.0.zip.sha256 +0 -1
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Existence-only classification of unresolved workspace wiki links whose
3
+ * target is a file inside the link workspace that is not an indexed document
4
+ * (an attachment, or a note in an unindexed or excluded folder). Obsidian
5
+ * resolves such links, so the link audit reports them as `outside-index`
6
+ * instead of unresolved. Only file names are listed: no file is opened,
7
+ * indexed, or returned, and no graph edge is created.
8
+ *
9
+ * @module src/core/audit-outside-index
10
+ */
11
+
12
+ import type { Database } from "bun:sqlite";
13
+
14
+ // node:fs/promises readdir/realpath/stat: directory enumeration and symlink
15
+ // resolution; Bun has no equivalent that can skip an unreadable folder
16
+ // instead of failing the scan.
17
+ import { readdir, realpath, stat } from "node:fs/promises";
18
+ // node:path join: platform path algebra; no Bun equivalent.
19
+ import { join } from "node:path";
20
+
21
+ import type { AuditLinkSnapshot } from "../store/sqlite/graph-link-resolver";
22
+
23
+ import {
24
+ createWorkspaceFileMatcher,
25
+ loadLinkWorkspaceMemberships,
26
+ } from "../store/sqlite/workspace-link-resolver";
27
+ import { pathContains, placeDocument } from "./link-workspace";
28
+
29
+ /** Upper bound of files listed per workspace; beyond it the listing is partial. */
30
+ export const WORKSPACE_FILE_LISTING_MAX_FILES = 200_000;
31
+
32
+ export interface WorkspaceFileListing {
33
+ /** Workspace-relative POSIX paths (NFC) of every non-hidden file. */
34
+ files: string[];
35
+ /** False when a folder could not be read or the file bound was reached. */
36
+ complete: boolean;
37
+ }
38
+
39
+ interface WorkspaceDirectoryEntry {
40
+ name: string;
41
+ isFile(): boolean;
42
+ isDirectory(): boolean;
43
+ isSymbolicLink(): boolean;
44
+ }
45
+
46
+ /** Filesystem reads the listing needs; injectable for tests. */
47
+ export interface WorkspaceFileSystem {
48
+ readDirectory(path: string): Promise<WorkspaceDirectoryEntry[]>;
49
+ realPath(path: string): Promise<string>;
50
+ isRegularFile(path: string): Promise<boolean>;
51
+ }
52
+
53
+ const nodeWorkspaceFileSystem: WorkspaceFileSystem = {
54
+ readDirectory: (path) => readdir(path, { withFileTypes: true }),
55
+ realPath: (path) => realpath(path),
56
+ isRegularFile: async (path) => (await stat(path)).isFile(),
57
+ };
58
+
59
+ /** A symlink target that does not exist (or loops) is not a file, not an error. */
60
+ const MISSING_TARGET_CODES = new Set(["ENOENT", "ENOTDIR", "ELOOP"]);
61
+
62
+ /**
63
+ * A symlink counts as a file only when it resolves to an existing regular
64
+ * file inside the workspace. Dangling links, links to folders and links that
65
+ * leave the workspace are not files. Other errors are reported as such.
66
+ */
67
+ const symlinkIsWorkspaceFile = async (
68
+ fs: WorkspaceFileSystem,
69
+ realRoot: string,
70
+ path: string
71
+ ): Promise<"file" | "not-file" | "error"> => {
72
+ try {
73
+ const target = await fs.realPath(path);
74
+ if (!pathContains(realRoot, target)) return "not-file";
75
+ return (await fs.isRegularFile(target)) ? "file" : "not-file";
76
+ } catch (cause) {
77
+ const code = (cause as NodeJS.ErrnoException | undefined)?.code;
78
+ return code !== undefined && MISSING_TARGET_CODES.has(code)
79
+ ? "not-file"
80
+ : "error";
81
+ }
82
+ };
83
+
84
+ /**
85
+ * List every file below a workspace root once. Hidden files and folders
86
+ * (`.obsidian`, `.trash`, `.git`) are skipped, as Obsidian does; symlinked
87
+ * folders are not descended. Filesystem calls use the names as stored on
88
+ * disk; only the returned paths are NFC-normalized for matching. An
89
+ * unreadable folder is skipped and marks the listing incomplete, so links
90
+ * into it stay unresolved.
91
+ */
92
+ export const listWorkspaceFiles = async (
93
+ root: string,
94
+ options: {
95
+ maxFiles?: number;
96
+ signal?: AbortSignal;
97
+ fileSystem?: WorkspaceFileSystem;
98
+ } = {}
99
+ ): Promise<WorkspaceFileListing> => {
100
+ const maxFiles = options.maxFiles ?? WORKSPACE_FILE_LISTING_MAX_FILES;
101
+ const fs = options.fileSystem ?? nodeWorkspaceFileSystem;
102
+ const files: string[] = [];
103
+ let complete = true;
104
+ let realRoot: string;
105
+ try {
106
+ realRoot = await fs.realPath(root);
107
+ } catch {
108
+ return { files, complete: false };
109
+ }
110
+ // Each folder keeps its on-disk segments; matching uses the NFC path.
111
+ const queue: string[][] = [[]];
112
+ for (let head = 0; head < queue.length; head += 1) {
113
+ options.signal?.throwIfAborted();
114
+ const segments = queue[head] as string[];
115
+ let entries;
116
+ try {
117
+ entries = await fs.readDirectory(join(root, ...segments));
118
+ } catch {
119
+ complete = false;
120
+ continue;
121
+ }
122
+ for (const entry of entries) {
123
+ if (entry.name.startsWith(".")) continue;
124
+ const entrySegments = [...segments, entry.name];
125
+ let isFile = entry.isFile();
126
+ if (entry.isDirectory()) {
127
+ queue.push(entrySegments);
128
+ continue;
129
+ }
130
+ if (entry.isSymbolicLink()) {
131
+ const kind = await symlinkIsWorkspaceFile(
132
+ fs,
133
+ realRoot,
134
+ join(root, ...entrySegments)
135
+ );
136
+ if (kind === "error") complete = false;
137
+ isFile = kind === "file";
138
+ }
139
+ if (!isFile) continue;
140
+ if (files.length >= maxFiles) return { files, complete: false };
141
+ files.push(entrySegments.join("/").normalize("NFC"));
142
+ }
143
+ }
144
+ return { files, complete };
145
+ };
146
+
147
+ /**
148
+ * Mark unresolved plain wiki links of audited documents inside a link
149
+ * workspace whose target exists as a workspace file. Each involved workspace
150
+ * is listed once per call. A failed or partial listing never marks a link
151
+ * that is not in it; the snapshot then carries one diagnostic.
152
+ */
153
+ export const markOutsideIndexLinks = async (
154
+ db: Database,
155
+ snapshot: AuditLinkSnapshot,
156
+ options: { maxFiles?: number; signal?: AbortSignal } = {}
157
+ ): Promise<AuditLinkSnapshot> => {
158
+ const audited = snapshot.auditedDocumentIds
159
+ ? new Set(snapshot.auditedDocumentIds)
160
+ : null;
161
+ const candidates = snapshot.links
162
+ .map((link, index) => ({ link, index }))
163
+ .filter(
164
+ ({ link }) =>
165
+ link.resolved === null &&
166
+ link.linkType === "wiki" &&
167
+ link.explicitCollection !== true &&
168
+ (audited === null || audited.has(link.sourceId))
169
+ );
170
+ if (candidates.length === 0) return snapshot;
171
+ const memberships = loadLinkWorkspaceMemberships(db);
172
+ const documentRelPaths = new Map(
173
+ snapshot.documents.map((document) => [document.id, document.relPath])
174
+ );
175
+ const placed = candidates.flatMap(({ link, index }) => {
176
+ const placement = placeDocument(
177
+ memberships.get(link.sourceCollection),
178
+ documentRelPaths.get(link.sourceId) ?? link.sourceRelPath
179
+ );
180
+ return placement.key === null
181
+ ? []
182
+ : [{ link, index, key: placement.key, sourcePath: placement.path }];
183
+ });
184
+ if (placed.length === 0) return snapshot;
185
+ const matchers = new Map<
186
+ string,
187
+ ReturnType<typeof createWorkspaceFileMatcher>
188
+ >();
189
+ const maxFiles = options.maxFiles ?? WORKSPACE_FILE_LISTING_MAX_FILES;
190
+ let incomplete = 0;
191
+ for (const key of new Set(placed.map((entry) => entry.key))) {
192
+ const listing = await listWorkspaceFiles(key, {
193
+ maxFiles,
194
+ signal: options.signal,
195
+ });
196
+ if (!listing.complete) incomplete += 1;
197
+ matchers.set(key, createWorkspaceFileMatcher(listing.files));
198
+ }
199
+ const links = [...snapshot.links];
200
+ for (const entry of placed) {
201
+ const matches = matchers.get(entry.key);
202
+ if (matches?.(entry.link.targetRefNorm, entry.sourcePath)) {
203
+ links[entry.index] = { ...entry.link, outsideIndex: true };
204
+ }
205
+ }
206
+ return {
207
+ ...snapshot,
208
+ links,
209
+ ...(incomplete > 0
210
+ ? {
211
+ outsideIndexDiagnostic: `The file listing of ${incomplete} link workspace${incomplete === 1 ? " was" : "s were"} incomplete (an unreadable folder or link, or more than ${maxFiles} files); links to files it missed stay unresolved`,
212
+ }
213
+ : {}),
214
+ };
215
+ };
@@ -32,6 +32,7 @@ import {
32
32
  } from "./audit";
33
33
  import { evaluateFreshnessAudit } from "./audit-freshness";
34
34
  import { evaluateLinkAudit } from "./audit-links";
35
+ import { markOutsideIndexLinks } from "./audit-outside-index";
35
36
  import { evaluateProvenanceAudit } from "./audit-provenance";
36
37
  import {
37
38
  extractCaptureSourceFromFrontmatter,
@@ -429,15 +430,20 @@ const loadWorkspaceSnapshot = async (
429
430
  ? captureAuditLinkSnapshot(options.store.getRawDb())
430
431
  : emptyLinkSnapshot();
431
432
  const selectedIds = new Set(selected.documents.map(({ id }) => id));
433
+ const links = filterLinkSnapshot(
434
+ rawLinks,
435
+ selectedIds,
436
+ selected.documents,
437
+ selected.truncated,
438
+ filters.collections
439
+ );
432
440
  return {
433
441
  documents: observed,
434
- links: filterLinkSnapshot(
435
- rawLinks,
436
- selectedIds,
437
- selected.documents,
438
- selected.truncated,
439
- filters.collections
440
- ),
442
+ links: options.categories.includes("links")
443
+ ? await markOutsideIndexLinks(options.store.getRawDb(), links, {
444
+ signal: options.signal,
445
+ })
446
+ : links,
441
447
  truncated: selected.truncated,
442
448
  };
443
449
  };
package/src/core/audit.ts CHANGED
@@ -226,8 +226,16 @@ const materializeRule = (
226
226
  )
227
227
  .sort(compareAuditFindings);
228
228
 
229
+ // Only warning/error findings fail a passing rule; info findings of an
230
+ // informational rule (links.outside-index) leave it passing.
229
231
  let status = contribution.status;
230
- if (findings.length > 0 && status === "pass") {
232
+ if (
233
+ status === "pass" &&
234
+ findings.some(
235
+ (finding) =>
236
+ finding.severity === "error" || finding.severity === "warning"
237
+ )
238
+ ) {
231
239
  status = "fail";
232
240
  }
233
241
 
@@ -23,7 +23,7 @@ import {
23
23
  pushInventoryToken,
24
24
  } from "./link-inventory-opaque";
25
25
  import { isRelevantDestination } from "./link-relevance";
26
- import { parseTargetParts } from "./links";
26
+ import { parseTargetParts, splitWikiLinkContent } from "./links";
27
27
 
28
28
  const EXTERNAL_URL_REGEX = /^[a-z][a-z0-9+.-]*:/i;
29
29
 
@@ -51,8 +51,7 @@ export function inventoryWikiLinks(
51
51
  }
52
52
 
53
53
  const content = match[1] ?? "";
54
- const pipeIndex = content.indexOf("|");
55
- const targetPart = pipeIndex >= 0 ? content.slice(0, pipeIndex) : content;
54
+ const targetPart = splitWikiLinkContent(content).target;
56
55
  const trimmedTarget = targetPart.trim();
57
56
  if (!trimmedTarget) continue;
58
57
  const leadingWs = targetPart.length - targetPart.trimStart().length;
package/src/core/links.ts CHANGED
@@ -88,6 +88,8 @@ const LOGSEQ_EMBED_REGEX =
88
88
  * Markdown inline link: [text](url)
89
89
  * Captures: 1=text, 2=url (path and optional anchor)
90
90
  * Negative lookbehind to avoid image links ![]()
91
+ * Link text may contain balanced square brackets one level deep
92
+ * (`[see [1]](note.md)`), as CommonMark allows.
91
93
  *
92
94
  * SCOPE LIMITATIONS:
93
95
  * - Only matches simple inline links [text](url)
@@ -95,7 +97,10 @@ const LOGSEQ_EMBED_REGEX =
95
97
  * - Does NOT match autolinks <url> or bare URLs
96
98
  * - Parens in URLs not supported (use %28 %29 encoding)
97
99
  */
98
- const MARKDOWN_LINK_REGEX = /(?<!!)\[([^\]]*)\]\(([^)]+)\)/g;
100
+ const MARKDOWN_LINK_REGEX = /(?<!!)\[((?:[^[\]]|\[[^[\]]*\])*)\]\(([^)]+)\)/g;
101
+
102
+ /** Square brackets in a destination mean the text was split, not a path. */
103
+ const BRACKET_IN_DESTINATION_REGEX = /[[\]]/;
99
104
 
100
105
  /** External URL pattern (http:// https:// mailto: etc.) */
101
106
  const EXTERNAL_URL_REGEX = /^[a-z][a-z0-9+.-]*:/i;
@@ -144,6 +149,26 @@ export function extractWikiBasename(ref: string): string {
144
149
  return stripWikiMdExt(base);
145
150
  }
146
151
 
152
+ /**
153
+ * Split the content of a wiki link into target and alias. Obsidian also reads
154
+ * `\|` (the pipe escaped inside a Markdown table) as the alias separator,
155
+ * so `[[Note\|Alias]]` targets `Note`, not `Note\`.
156
+ */
157
+ export function splitWikiLinkContent(content: string): {
158
+ target: string;
159
+ alias?: string;
160
+ } {
161
+ const pipeIndex = content.indexOf("|");
162
+ if (pipeIndex < 0) {
163
+ return { target: content };
164
+ }
165
+ const escaped = pipeIndex > 0 && content[pipeIndex - 1] === "\\";
166
+ return {
167
+ target: content.slice(0, escaped ? pipeIndex - 1 : pipeIndex),
168
+ alias: content.slice(pipeIndex + 1),
169
+ };
170
+ }
171
+
147
172
  // ─────────────────────────────────────────────────────────────────────────────
148
173
  // Path Normalization
149
174
  // ─────────────────────────────────────────────────────────────────────────────
@@ -338,22 +363,14 @@ export function parseLinks(
338
363
  const content = match[1];
339
364
  if (!content) continue;
340
365
 
341
- // Parse [[target|alias]] format
342
- const pipeIndex = content.indexOf("|");
343
- let targetPart: string;
344
- let displayText: string | undefined;
345
-
346
- if (pipeIndex >= 0) {
347
- targetPart = content.slice(0, pipeIndex);
348
- const aliasText = content.slice(pipeIndex + 1);
349
- // Only set displayText if different from target
350
- displayText =
351
- aliasText !== targetPart
352
- ? truncateText(aliasText, MAX_DISPLAY_TEXT_GRAPHEMES)
353
- : undefined;
354
- } else {
355
- targetPart = content;
356
- }
366
+ // Parse [[target|alias]] (and table-escaped [[target\|alias]]) format
367
+ const { target: targetPart, alias: aliasText } =
368
+ splitWikiLinkContent(content);
369
+ // Only set displayText if different from target
370
+ const displayText =
371
+ aliasText !== undefined && aliasText !== targetPart
372
+ ? truncateText(aliasText, MAX_DISPLAY_TEXT_GRAPHEMES)
373
+ : undefined;
357
374
 
358
375
  const trimmedTarget = targetPart.trim();
359
376
  if (!trimmedTarget) {
@@ -427,6 +444,12 @@ export function parseLinks(
427
444
  continue;
428
445
  }
429
446
 
447
+ // Brackets in the destination come from link text split at an
448
+ // unbalanced bracket (`[a [b](c](d)`): unparseable, not a missing target.
449
+ if (BRACKET_IN_DESTINATION_REGEX.test(url)) {
450
+ continue;
451
+ }
452
+
430
453
  // Skip URLs that look like protocol-relative (//example.com)
431
454
  if (url.startsWith("//")) {
432
455
  continue;
@@ -30,6 +30,9 @@ import {
30
30
  } from "./memory-fence";
31
31
  import {
32
32
  MEMORY_EMPTY_RECALL_HINT,
33
+ MEMORY_NO_MATCH_RECALL_HINT,
34
+ MEMORY_OVER_BUDGET_RECALL_HINT,
35
+ MEMORY_RECALL_ANY_TERM_MIN_RELATIVE_SCORE,
33
36
  MEMORY_RECALL_MAX_FACTS,
34
37
  MEMORY_RECALL_MAX_TOKENS,
35
38
  MEMORY_RECALL_RETRIEVAL_LIMIT,
@@ -40,6 +43,218 @@ import {
40
43
 
41
44
  type RetrievalLeg = { source: "bm25" | "vector"; results: SearchResult[] };
42
45
 
46
+ /**
47
+ * Function words dropped from the lexical leg so a question-shaped turn
48
+ * retrieves on its content terms. English plus the common German, French,
49
+ * and Italian question and function words.
50
+ */
51
+ const RECALL_STOPWORDS = new Set([
52
+ // English
53
+ "a",
54
+ "about",
55
+ "am",
56
+ "an",
57
+ "and",
58
+ "any",
59
+ "anything",
60
+ "are",
61
+ "as",
62
+ "at",
63
+ "be",
64
+ "been",
65
+ "but",
66
+ "by",
67
+ "can",
68
+ "could",
69
+ "did",
70
+ "do",
71
+ "does",
72
+ "for",
73
+ "from",
74
+ "had",
75
+ "has",
76
+ "have",
77
+ "how",
78
+ "how's",
79
+ "i",
80
+ "if",
81
+ "in",
82
+ "into",
83
+ "is",
84
+ "it",
85
+ "it's",
86
+ "its",
87
+ "know",
88
+ "me",
89
+ "my",
90
+ "of",
91
+ "on",
92
+ "or",
93
+ "our",
94
+ "please",
95
+ "should",
96
+ "so",
97
+ "tell",
98
+ "that",
99
+ "the",
100
+ "their",
101
+ "them",
102
+ "there",
103
+ "these",
104
+ "they",
105
+ "this",
106
+ "those",
107
+ "to",
108
+ "us",
109
+ "was",
110
+ "we",
111
+ "were",
112
+ "what",
113
+ "what's",
114
+ "when",
115
+ "where",
116
+ "where's",
117
+ "which",
118
+ "who",
119
+ "who's",
120
+ "whom",
121
+ "whose",
122
+ "why",
123
+ "will",
124
+ "with",
125
+ "would",
126
+ "you",
127
+ "your",
128
+ // German
129
+ "das",
130
+ "dem",
131
+ "den",
132
+ "der",
133
+ "des",
134
+ "ein",
135
+ "eine",
136
+ "ist",
137
+ "mit",
138
+ "oder",
139
+ "sind",
140
+ "und",
141
+ "uns",
142
+ "von",
143
+ "wann",
144
+ "warum",
145
+ "welche",
146
+ "welcher",
147
+ "welches",
148
+ "wer",
149
+ "wie",
150
+ "wir",
151
+ "wo",
152
+ "zu",
153
+ // French
154
+ "avec",
155
+ "comment",
156
+ "dans",
157
+ "de",
158
+ "du",
159
+ "est",
160
+ "et",
161
+ "la",
162
+ "le",
163
+ "les",
164
+ "nous",
165
+ "ou",
166
+ "où",
167
+ "pour",
168
+ "pourquoi",
169
+ "quand",
170
+ "que",
171
+ "quel",
172
+ "quelle",
173
+ "qui",
174
+ "quoi",
175
+ "sont",
176
+ "sur",
177
+ "un",
178
+ "une",
179
+ "vous",
180
+ // Italian
181
+ "che",
182
+ "chi",
183
+ "come",
184
+ "con",
185
+ "cosa",
186
+ "da",
187
+ "del",
188
+ "della",
189
+ "di",
190
+ "dove",
191
+ "il",
192
+ "per",
193
+ "perché",
194
+ "quale",
195
+ "quali",
196
+ "sono",
197
+ ]);
198
+
199
+ /** Whitespace tokens, keeping a quoted phrase (optionally negated) whole. */
200
+ const QUERY_TOKEN_PATTERN = /-?"[^"]*"|\S+/g;
201
+ /** Same character class the FTS term sanitizer keeps. */
202
+ const NON_TERM_CHARS = /[^\p{L}\p{N}'_]/gu;
203
+
204
+ /**
205
+ * Content terms of a recall query: bare stopword tokens are dropped; quoted
206
+ * phrases, negations, and compounds pass through. A query with no positive
207
+ * content term left is returned unchanged.
208
+ */
209
+ function recallContentQuery(query: string): string {
210
+ const tokens = query.match(QUERY_TOKEN_PATTERN) ?? [];
211
+ const kept = tokens.filter((token) => {
212
+ if (token.startsWith("-") || token.includes('"')) return true;
213
+ return !RECALL_STOPWORDS.has(
214
+ token.replace(NON_TERM_CHARS, "").toLowerCase()
215
+ );
216
+ });
217
+ const hasPositiveTerm = kept.some(
218
+ (token) =>
219
+ !token.startsWith("-") && token.replace(NON_TERM_CHARS, "").length > 0
220
+ );
221
+ return hasPositiveTerm ? kept.join(" ") : query;
222
+ }
223
+
224
+ /**
225
+ * Lexical leg: BM25 over the content terms, every term required first. When
226
+ * no fact carries all of them (a question-shaped turn), fall back to
227
+ * any-term matching so facts sharing a content term still rank, best BM25
228
+ * first, above a relative score floor. `null` means the query has no
229
+ * searchable terms.
230
+ */
231
+ async function searchLexical(
232
+ deps: MemoryServiceDeps,
233
+ input: { query: string; collection: string; scopes: string[] }
234
+ ): Promise<SearchResult[] | null> {
235
+ const contentQuery = recallContentQuery(input.query);
236
+ const run = async (anyTerm: boolean) => {
237
+ const bm25 = await searchBm25(deps.store, contentQuery, {
238
+ collection: input.collection,
239
+ limit: MEMORY_RECALL_RETRIEVAL_LIMIT,
240
+ memoryFilter: { scopes: input.scopes, excludeSuperseded: true },
241
+ ...(anyTerm
242
+ ? {
243
+ anyTerm,
244
+ minRelativeScore: MEMORY_RECALL_ANY_TERM_MIN_RELATIVE_SCORE,
245
+ }
246
+ : {}),
247
+ });
248
+ if (bm25.ok) return bm25.value.results;
249
+ if (bm25.error.code !== "INVALID_INPUT") {
250
+ throw new MemoryError("MEMORY_QUERY_FAILED", bm25.error.message);
251
+ }
252
+ return null;
253
+ };
254
+ const allTerms = await run(false);
255
+ return allTerms?.length === 0 ? run(true) : allTerms;
256
+ }
257
+
43
258
  /**
44
259
  * Retrieval legs: BM25 always; vectors when an embedding port and a searchable
45
260
  * vector index are present. The eligible set is one unbounded in-query
@@ -52,17 +267,9 @@ async function retrieveLegs(
52
267
  const { store, config } = deps;
53
268
  const { query, collection, scopes } = input;
54
269
  const legs: RetrievalLeg[] = [];
55
- const bm25 = await searchBm25(store, query, {
56
- collection,
57
- limit: MEMORY_RECALL_RETRIEVAL_LIMIT,
58
- memoryFilter: { scopes, excludeSuperseded: true },
59
- });
60
- if (!bm25.ok) {
61
- if (bm25.error.code !== "INVALID_INPUT") {
62
- throw new MemoryError("MEMORY_QUERY_FAILED", bm25.error.message);
63
- }
64
- } else {
65
- legs.push({ source: "bm25", results: bm25.value.results });
270
+ const lexical = await searchLexical(deps, input);
271
+ if (lexical) {
272
+ legs.push({ source: "bm25", results: lexical });
66
273
  }
67
274
 
68
275
  const retrieval: RecallResult["retrieval"] = { mode: "lexical" };
@@ -159,7 +366,11 @@ async function materializeFacts(
159
366
  return materialized;
160
367
  }
161
368
 
162
- /** Token budget via the shared context-evidence selector, then the fact cap. */
369
+ /**
370
+ * Token budget via the shared context-evidence selector, then the fact cap.
371
+ * Facts carry no facets, so the selector fills the budget in retrieval-rank
372
+ * order (per-fact facets would make it prefer the shortest facts).
373
+ */
163
374
  function selectWithinBudget(
164
375
  materialized: Array<{ fact: RecalledFact; rank: number }>,
165
376
  maxFacts: number,
@@ -176,11 +387,11 @@ function selectWithinBudget(
176
387
  sourceHash: fact.contentHash,
177
388
  mirrorHash: fact.contentHash,
178
389
  text: fact.text,
179
- facets: [fact.uri],
390
+ facets: [],
180
391
  retrievalRank: rank,
181
392
  value: fact,
182
393
  })),
183
- requestedFacets: materialized.map(({ fact }) => fact.uri),
394
+ requestedFacets: [],
184
395
  limits: {
185
396
  requestedBytes: maxTokens * MEMORY_TOKEN_BYTES_ESTIMATE,
186
397
  requestedTokens: maxTokens,
@@ -201,6 +412,28 @@ function selectWithinBudget(
201
412
  return selection.selected.slice(0, maxFacts).map((item) => item.value);
202
413
  }
203
414
 
415
+ /**
416
+ * Why nothing came back: facts matched but none fit the budget, the scope
417
+ * holds facts but none matched, or the scope holds no current fact at all.
418
+ */
419
+ async function emptyRecallHint(
420
+ deps: MemoryServiceDeps,
421
+ input: { collection: string; scopes: string[]; matched: number }
422
+ ): Promise<string> {
423
+ if (input.matched > 0) return MEMORY_OVER_BUDGET_RECALL_HINT;
424
+ const eligible = await deps.store.listMemoryEligibleDocuments({
425
+ collection: input.collection,
426
+ scopes: input.scopes,
427
+ excludeSuperseded: true,
428
+ });
429
+ if (!eligible.ok) {
430
+ throw new MemoryError("MEMORY_QUERY_FAILED", eligible.error.message);
431
+ }
432
+ return eligible.value.length > 0
433
+ ? MEMORY_NO_MATCH_RECALL_HINT
434
+ : MEMORY_EMPTY_RECALL_HINT;
435
+ }
436
+
204
437
  export async function recallFacts(
205
438
  deps: MemoryServiceDeps,
206
439
  rawInput: RecallInput
@@ -264,6 +497,12 @@ export async function recallFacts(
264
497
  facts.map((fact) => fact.egressLineage)
265
498
  ),
266
499
  }
267
- : { hint: MEMORY_EMPTY_RECALL_HINT }),
500
+ : {
501
+ hint: await emptyRecallHint(deps, {
502
+ collection: collection.name,
503
+ scopes,
504
+ matched: materialized.length,
505
+ }),
506
+ }),
268
507
  };
269
508
  }