@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.
- package/README.md +1 -1
- package/assets/skill/SKILL.md +5 -2
- package/assets/skill/recipes/memory-scoped-recall.md +10 -5
- package/assets/spa-production.json.gz +0 -0
- package/browser-extension/artifacts/{gno-browser-clipper-v2.8.0.zip → gno-browser-clipper-v2.8.1.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v2.8.1.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +1 -1
- package/spec/cli.md +41 -6
- package/spec/mcp.md +8 -4
- package/spec/output-schemas/memory-recall.schema.json +1 -1
- package/spec/output-schemas/status.schema.json +3 -3
- package/src/cli/commands/graph.ts +3 -1
- package/src/cli/commands/links.ts +27 -49
- package/src/cli/commands/status.ts +1 -0
- package/src/core/audit-links.ts +56 -4
- package/src/core/audit-outside-index.ts +215 -0
- package/src/core/audit-workspace.ts +13 -7
- package/src/core/audit.ts +9 -1
- package/src/core/link-inventory-markdown.ts +2 -3
- package/src/core/links.ts +40 -17
- package/src/core/memory-recall.ts +254 -15
- package/src/core/memory-types.ts +12 -0
- package/src/core/memory.ts +2 -0
- package/src/ingestion/sync.ts +5 -3
- package/src/mcp/tools/links.ts +71 -93
- package/src/mcp/tools/status.ts +1 -0
- package/src/pipeline/search.ts +2 -0
- package/src/pipeline/types.ts +4 -0
- package/src/sdk/client.ts +1 -0
- package/src/serve/public/components/editor/MarkdownPreview.tsx +5 -3
- package/src/serve/routes/graph.ts +3 -1
- package/src/serve/routes/links.ts +32 -50
- package/src/serve/server.ts +2 -1
- package/src/serve/status.ts +1 -0
- package/src/store/sqlite/adapter.ts +87 -106
- package/src/store/sqlite/graph-link-resolver.ts +7 -0
- package/src/store/sqlite/graph-similarity.ts +96 -0
- package/src/store/sqlite/workspace-link-resolver.ts +119 -31
- package/src/store/types.ts +15 -2
- package/src/store/vector/status.ts +27 -0
- package/src/store/vector/stored-vectors.ts +158 -0
- package/src/store/vector/types.ts +6 -0
- package/src/store/vector/variant-search.ts +30 -14
- 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:
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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 (
|
|
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
|
|
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 = /(?<!!)\[([
|
|
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
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
/**
|
|
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: [
|
|
390
|
+
facets: [],
|
|
180
391
|
retrievalRank: rank,
|
|
181
392
|
value: fact,
|
|
182
393
|
})),
|
|
183
|
-
requestedFacets:
|
|
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
|
-
: {
|
|
500
|
+
: {
|
|
501
|
+
hint: await emptyRecallHint(deps, {
|
|
502
|
+
collection: collection.name,
|
|
503
|
+
scopes,
|
|
504
|
+
matched: materialized.length,
|
|
505
|
+
}),
|
|
506
|
+
}),
|
|
268
507
|
};
|
|
269
508
|
}
|