@gmickel/gno 1.39.2 → 1.41.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.
- package/README.md +1 -0
- package/assets/skill/SKILL.md +17 -0
- package/assets/skill/cli-reference.md +48 -0
- package/assets/skill/mcp-reference.md +24 -0
- package/assets/spa-production.json.gz +0 -0
- package/browser-extension/artifacts/{gno-browser-clipper-v1.39.2.zip → gno-browser-clipper-v1.41.0.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.41.0.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +1 -1
- package/spec/cli.md +146 -7
- package/spec/db/schema.sql +17 -0
- package/spec/mcp.md +194 -0
- package/spec/output-schemas/memory-recall.schema.json +159 -0
- package/spec/output-schemas/memory-remember.schema.json +164 -0
- package/spec/output-schemas/status.schema.json +269 -54
- package/src/cli/commands/memory.ts +491 -0
- package/src/cli/commands/status.ts +23 -4
- package/src/cli/options.ts +4 -0
- package/src/cli/program.ts +127 -0
- package/src/config/types.ts +7 -0
- package/src/core/audit-provenance.ts +91 -0
- package/src/core/audit-workspace.ts +17 -0
- package/src/core/memory-diagnostics.ts +144 -0
- package/src/core/memory-fence.ts +239 -0
- package/src/core/memory-recall.ts +269 -0
- package/src/core/memory-record.ts +435 -0
- package/src/core/memory-remember.ts +425 -0
- package/src/core/memory-types.ts +211 -0
- package/src/core/memory.ts +87 -0
- package/src/core/network-boundary-inventory.ts +10 -0
- package/src/ingestion/sync.ts +17 -0
- package/src/mcp/http-egress.ts +2 -0
- package/src/mcp/tools/index.ts +43 -0
- package/src/mcp/tools/memory-recall.ts +122 -0
- package/src/mcp/tools/memory-remember.ts +177 -0
- package/src/mcp/tools/memory-shared.ts +80 -0
- package/src/pipeline/search.ts +2 -0
- package/src/pipeline/types.ts +8 -0
- package/src/sdk/client.ts +94 -1
- package/src/sdk/index.ts +13 -0
- package/src/sdk/types.ts +28 -0
- package/src/serve/CLAUDE.md +22 -2
- package/src/serve/clipper-security.ts +3 -17
- package/src/serve/public/components/pdf/PdfViewer.tsx +97 -19
- package/src/serve/public/globals.built.css +1 -1
- package/src/serve/public/globals.css +4 -0
- package/src/serve/public/hooks/use-pdf-document.ts +101 -37
- package/src/serve/public/hooks/use-pdf-pages.ts +324 -92
- package/src/serve/public/lib/pdf-transport.ts +13 -0
- package/src/serve/public/lib/pdf.ts +77 -10
- package/src/serve/public/lib/server-capabilities.ts +19 -0
- package/src/serve/public/pages/Ask.tsx +8 -9
- package/src/serve/public/pages/DocView.tsx +80 -18
- package/src/serve/public/pages/Search.tsx +8 -9
- package/src/serve/request-locality.ts +84 -0
- package/src/serve/routes/api.ts +255 -3
- package/src/serve/server.ts +189 -29
- package/src/store/migrations/027-memory-scopes.ts +37 -0
- package/src/store/migrations/index.ts +2 -0
- package/src/store/sqlite/adapter.ts +127 -3
- package/src/store/types.ts +54 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.39.2.zip.sha256 +0 -1
package/src/serve/server.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { HttpGatewayOverrides } from "../mcp/http-security";
|
|
10
|
+
import type { RequestPeerServer } from "./request-locality";
|
|
10
11
|
import type { ResidentRuntime } from "./resident-runtime";
|
|
11
12
|
import type { ContextHolder } from "./routes/api";
|
|
12
13
|
|
|
@@ -22,6 +23,7 @@ import {
|
|
|
22
23
|
handlePdfjsVendorRequest,
|
|
23
24
|
isPdfjsVendorPath,
|
|
24
25
|
} from "./fn112-routes";
|
|
26
|
+
import { PDFJS_ASSET_CACHE_CONTROL } from "./pdfjs-assets";
|
|
25
27
|
// HTML import - Bun handles bundling TSX/CSS automatically via routes
|
|
26
28
|
import homepage from "./public/index.html";
|
|
27
29
|
import { handleResidentRead } from "./resident-request";
|
|
@@ -57,6 +59,8 @@ import {
|
|
|
57
59
|
handleHealth,
|
|
58
60
|
handleImportPreview,
|
|
59
61
|
handleInstallConnector,
|
|
62
|
+
handleMemoryRecall,
|
|
63
|
+
handleMemoryRemember,
|
|
60
64
|
handleMoveDoc,
|
|
61
65
|
handleNotePresets,
|
|
62
66
|
handleJob,
|
|
@@ -209,6 +213,156 @@ export function withSecurityHeaders(
|
|
|
209
213
|
}
|
|
210
214
|
}
|
|
211
215
|
|
|
216
|
+
/**
|
|
217
|
+
* Hashed SPA chunk paths emitted by the production split build
|
|
218
|
+
* (`/chunk-<hash>.js|css`). The entry HTML never matches, so it keeps its
|
|
219
|
+
* existing headers and ETag pass-through.
|
|
220
|
+
*/
|
|
221
|
+
const SPA_HASHED_CHUNK_RE = /^\/chunk-[A-Za-z0-9]+\.(?:js|css)$/u;
|
|
222
|
+
|
|
223
|
+
/** One-year immutable policy shared with the version-pinned pdfjs assets. */
|
|
224
|
+
export const SPA_CHUNK_CACHE_CONTROL = PDFJS_ASSET_CACHE_CONTROL;
|
|
225
|
+
|
|
226
|
+
export const isHashedSpaChunkPath = (pathname: string): boolean =>
|
|
227
|
+
SPA_HASHED_CHUNK_RE.test(pathname);
|
|
228
|
+
|
|
229
|
+
const GZIP_CODINGS = new Set(["gzip", "x-gzip"]);
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* True when an Accept-Encoding value accepts gzip: gzip (or x-gzip) listed
|
|
233
|
+
* with a non-zero q-value, or a bare `*` with a non-zero q-value while gzip
|
|
234
|
+
* is not explicitly refused (RFC 9110 §12.5.3: `*` matches any coding not
|
|
235
|
+
* otherwise listed). Absent header → identity only.
|
|
236
|
+
*/
|
|
237
|
+
export function acceptsGzip(acceptEncoding: string | null): boolean {
|
|
238
|
+
if (!acceptEncoding) {
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
let gzipQ: number | undefined;
|
|
242
|
+
let wildcardQ: number | undefined;
|
|
243
|
+
for (const member of acceptEncoding.split(",")) {
|
|
244
|
+
const [rawCoding, ...params] = member.trim().toLowerCase().split(";");
|
|
245
|
+
const coding = rawCoding?.trim();
|
|
246
|
+
if (!coding) {
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
const q = params
|
|
250
|
+
.map((param) => param.trim())
|
|
251
|
+
.find((param) => param.startsWith("q="))
|
|
252
|
+
?.slice(2);
|
|
253
|
+
const weight = q === undefined ? 1 : Number.parseFloat(q);
|
|
254
|
+
if (GZIP_CODINGS.has(coding)) {
|
|
255
|
+
gzipQ = Math.max(gzipQ ?? 0, Number.isNaN(weight) ? 0 : weight);
|
|
256
|
+
} else if (coding === "*") {
|
|
257
|
+
wildcardQ = Math.max(wildcardQ ?? 0, Number.isNaN(weight) ? 0 : weight);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (gzipQ !== undefined) {
|
|
261
|
+
return gzipQ > 0;
|
|
262
|
+
}
|
|
263
|
+
return (wildcardQ ?? 0) > 0;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export type PublicFetchFallbackOptions = {
|
|
267
|
+
isDev: boolean;
|
|
268
|
+
spaBundleSource: SpaBundleSource | null;
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Production catch-all `fetch` for the public listener: the pdfjs vendor
|
|
273
|
+
* prefix, then the private SPA source. Encoding negotiation lives here, not in
|
|
274
|
+
* the source, because `hostPrivateSource` re-issues internal requests with
|
|
275
|
+
* only the method, so `Accept-Encoding` never reaches it. Hashed chunks gain
|
|
276
|
+
* an immutable cache policy and a gzip body computed once per pathname;
|
|
277
|
+
* identity clients get the same cache headers on the original bytes. Tests
|
|
278
|
+
* mount this same factory on a real listener (fn-112 I1-04 discipline).
|
|
279
|
+
*/
|
|
280
|
+
export function createPublicFetchFallback(
|
|
281
|
+
options: PublicFetchFallbackOptions
|
|
282
|
+
): (req: Request) => Promise<Response> {
|
|
283
|
+
const { isDev, spaBundleSource } = options;
|
|
284
|
+
const gzipBodies = new Map<string, Uint8Array<ArrayBuffer>>();
|
|
285
|
+
|
|
286
|
+
const gzipBodyFor = async (
|
|
287
|
+
req: Request,
|
|
288
|
+
pathname: string,
|
|
289
|
+
asset: Response
|
|
290
|
+
): Promise<Uint8Array<ArrayBuffer>> => {
|
|
291
|
+
const cached = gzipBodies.get(pathname);
|
|
292
|
+
if (cached) {
|
|
293
|
+
await asset.body?.cancel();
|
|
294
|
+
return cached;
|
|
295
|
+
}
|
|
296
|
+
// HEAD reaches the source as HEAD, so its body is empty; fetch the bytes.
|
|
297
|
+
const identity =
|
|
298
|
+
req.method === "HEAD" && spaBundleSource
|
|
299
|
+
? await spaBundleSource.fetch(new Request(req.url, { method: "GET" }))
|
|
300
|
+
: asset;
|
|
301
|
+
// Copy into an ArrayBuffer-backed view so the cached bytes are a BodyInit.
|
|
302
|
+
const encoded = new Uint8Array(
|
|
303
|
+
Bun.gzipSync(new Uint8Array(await identity.arrayBuffer()))
|
|
304
|
+
);
|
|
305
|
+
gzipBodies.set(pathname, encoded);
|
|
306
|
+
return encoded;
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
const serveHashedChunk = async (
|
|
310
|
+
req: Request,
|
|
311
|
+
pathname: string,
|
|
312
|
+
asset: Response
|
|
313
|
+
): Promise<Response> => {
|
|
314
|
+
const headers = new Headers(asset.headers);
|
|
315
|
+
headers.set("Cache-Control", SPA_CHUNK_CACHE_CONTROL);
|
|
316
|
+
headers.set("Vary", "Accept-Encoding");
|
|
317
|
+
if (!acceptsGzip(req.headers.get("accept-encoding"))) {
|
|
318
|
+
if (req.method === "HEAD") {
|
|
319
|
+
// HEAD reaches the source as HEAD: its body is empty but its headers
|
|
320
|
+
// (Content-Length included) already describe the GET body. Keep them.
|
|
321
|
+
await asset.body?.cancel();
|
|
322
|
+
return new Response(null, { status: asset.status, headers });
|
|
323
|
+
}
|
|
324
|
+
// Buffer the identity bytes: re-wrapping the proxied stream would drop
|
|
325
|
+
// Content-Length and fall back to chunked transfer.
|
|
326
|
+
const identity = await asset.arrayBuffer();
|
|
327
|
+
headers.set("Content-Length", String(identity.byteLength));
|
|
328
|
+
return new Response(identity, { status: asset.status, headers });
|
|
329
|
+
}
|
|
330
|
+
const encoded = await gzipBodyFor(req, pathname, asset);
|
|
331
|
+
headers.set("Content-Encoding", "gzip");
|
|
332
|
+
headers.set("Content-Length", String(encoded.byteLength));
|
|
333
|
+
return new Response(req.method === "HEAD" ? null : encoded, {
|
|
334
|
+
status: asset.status,
|
|
335
|
+
headers,
|
|
336
|
+
});
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
return async (req: Request): Promise<Response> => {
|
|
340
|
+
const pathname = new URL(req.url).pathname;
|
|
341
|
+
if (isPdfjsVendorPath(pathname)) {
|
|
342
|
+
return handlePdfjsVendorRequest(req, {
|
|
343
|
+
isDev,
|
|
344
|
+
withSecurityHeaders,
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
if (spaBundleSource && (req.method === "GET" || req.method === "HEAD")) {
|
|
348
|
+
const asset = await spaBundleSource.fetch(req);
|
|
349
|
+
if (asset.status === 200 && !isDev && isHashedSpaChunkPath(pathname)) {
|
|
350
|
+
return withSecurityHeaders(
|
|
351
|
+
await serveHashedChunk(req, pathname, asset),
|
|
352
|
+
isDev
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
if (asset.status !== 404) {
|
|
356
|
+
return withSecurityHeaders(asset, isDev);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return withSecurityHeaders(
|
|
360
|
+
new Response("Not Found", { status: 404 }),
|
|
361
|
+
isDev
|
|
362
|
+
);
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
212
366
|
/** Build a loopback origin with correct IPv6 authority formatting. */
|
|
213
367
|
export function loopbackHttpOrigin(host: string, port: number): string {
|
|
214
368
|
const normalizedHost =
|
|
@@ -676,6 +830,30 @@ export async function startServer(
|
|
|
676
830
|
);
|
|
677
831
|
},
|
|
678
832
|
},
|
|
833
|
+
"/api/memory/remember": {
|
|
834
|
+
POST: async (req: Request) => {
|
|
835
|
+
if (!isRequestAllowed(req, port)) {
|
|
836
|
+
return withSecurityHeaders(forbiddenResponse(), isDev);
|
|
837
|
+
}
|
|
838
|
+
return withSecurityHeaders(
|
|
839
|
+
await handleMemoryRemember(ctxHolder, store, req),
|
|
840
|
+
isDev
|
|
841
|
+
);
|
|
842
|
+
},
|
|
843
|
+
},
|
|
844
|
+
"/api/memory/recall": {
|
|
845
|
+
POST: async (req: Request) => {
|
|
846
|
+
if (!isRequestAllowed(req, port)) {
|
|
847
|
+
return withSecurityHeaders(forbiddenResponse(), isDev);
|
|
848
|
+
}
|
|
849
|
+
return withSecurityHeaders(
|
|
850
|
+
await handleResidentRead(runtime as ResidentRuntime, req, () =>
|
|
851
|
+
handleMemoryRecall(ctxHolder, store, req)
|
|
852
|
+
),
|
|
853
|
+
isDev
|
|
854
|
+
);
|
|
855
|
+
},
|
|
856
|
+
},
|
|
679
857
|
"/api/docs": {
|
|
680
858
|
GET: async (req: Request) => {
|
|
681
859
|
const url = new URL(req.url);
|
|
@@ -824,7 +1002,7 @@ export async function startServer(
|
|
|
824
1002
|
},
|
|
825
1003
|
},
|
|
826
1004
|
"/api/docs/:id/reveal": {
|
|
827
|
-
POST: async (req: Request) => {
|
|
1005
|
+
POST: async (req: Request, server: RequestPeerServer) => {
|
|
828
1006
|
if (!isRequestAllowed(req, port)) {
|
|
829
1007
|
return withSecurityHeaders(forbiddenResponse(), isDev);
|
|
830
1008
|
}
|
|
@@ -832,7 +1010,7 @@ export async function startServer(
|
|
|
832
1010
|
const parts = url.pathname.split("/");
|
|
833
1011
|
const id = decodeURIComponent(parts[3] || "");
|
|
834
1012
|
return withSecurityHeaders(
|
|
835
|
-
await handleRevealDoc(ctxHolder, store, id, req),
|
|
1013
|
+
await handleRevealDoc(ctxHolder, store, id, req, { server }),
|
|
836
1014
|
isDev
|
|
837
1015
|
);
|
|
838
1016
|
},
|
|
@@ -1015,8 +1193,11 @@ export async function startServer(
|
|
|
1015
1193
|
},
|
|
1016
1194
|
},
|
|
1017
1195
|
"/api/capabilities": {
|
|
1018
|
-
GET: () =>
|
|
1019
|
-
withSecurityHeaders(
|
|
1196
|
+
GET: (req: Request, server: RequestPeerServer) =>
|
|
1197
|
+
withSecurityHeaders(
|
|
1198
|
+
handleCapabilities(ctxHolder.current, req, server),
|
|
1199
|
+
isDev
|
|
1200
|
+
),
|
|
1020
1201
|
},
|
|
1021
1202
|
"/api/presets": {
|
|
1022
1203
|
GET: () =>
|
|
@@ -1292,31 +1473,10 @@ export async function startServer(
|
|
|
1292
1473
|
},
|
|
1293
1474
|
},
|
|
1294
1475
|
},
|
|
1295
|
-
// Production
|
|
1296
|
-
//
|
|
1297
|
-
//
|
|
1298
|
-
fetch:
|
|
1299
|
-
const pathname = new URL(req.url).pathname;
|
|
1300
|
-
if (isPdfjsVendorPath(pathname)) {
|
|
1301
|
-
return handlePdfjsVendorRequest(req, {
|
|
1302
|
-
isDev,
|
|
1303
|
-
withSecurityHeaders,
|
|
1304
|
-
});
|
|
1305
|
-
}
|
|
1306
|
-
if (
|
|
1307
|
-
spaBundleSource &&
|
|
1308
|
-
(req.method === "GET" || req.method === "HEAD")
|
|
1309
|
-
) {
|
|
1310
|
-
const asset = await spaBundleSource.fetch(req);
|
|
1311
|
-
if (asset.status !== 404) {
|
|
1312
|
-
return withSecurityHeaders(asset, isDev);
|
|
1313
|
-
}
|
|
1314
|
-
}
|
|
1315
|
-
return withSecurityHeaders(
|
|
1316
|
-
new Response("Not Found", { status: 404 }),
|
|
1317
|
-
isDev
|
|
1318
|
-
);
|
|
1319
|
-
},
|
|
1476
|
+
// Production catch-all: /vendor/pdfjs prefix, then hashed SPA chunks
|
|
1477
|
+
// (gzip + immutable) and the private SPA source — the same factory the
|
|
1478
|
+
// tests mount (no test-only fallback path).
|
|
1479
|
+
fetch: createPublicFetchFallback({ isDev, spaBundleSource }),
|
|
1320
1480
|
});
|
|
1321
1481
|
} catch (e) {
|
|
1322
1482
|
removeShutdownHandlers();
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migration: indexed memory scopes for managed memory records.
|
|
3
|
+
*
|
|
4
|
+
* Scopes are filterable inside retrieval queries (never post-hoc over a
|
|
5
|
+
* bounded candidate window), so they live in their own indexed table.
|
|
6
|
+
*
|
|
7
|
+
* @module src/store/migrations/027-memory-scopes
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Database } from "bun:sqlite";
|
|
11
|
+
|
|
12
|
+
import type { Migration } from "./runner";
|
|
13
|
+
|
|
14
|
+
export const migration: Migration = {
|
|
15
|
+
version: 27,
|
|
16
|
+
name: "memory_scopes",
|
|
17
|
+
|
|
18
|
+
up(db: Database): void {
|
|
19
|
+
db.exec(`
|
|
20
|
+
CREATE TABLE IF NOT EXISTS doc_memory_scopes (
|
|
21
|
+
document_id INTEGER NOT NULL,
|
|
22
|
+
scope TEXT NOT NULL,
|
|
23
|
+
PRIMARY KEY (document_id, scope),
|
|
24
|
+
FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE
|
|
25
|
+
)
|
|
26
|
+
`);
|
|
27
|
+
db.exec(`
|
|
28
|
+
CREATE INDEX IF NOT EXISTS idx_doc_memory_scopes_scope
|
|
29
|
+
ON doc_memory_scopes(scope, document_id)
|
|
30
|
+
`);
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
down(db: Database): void {
|
|
34
|
+
db.exec("DROP INDEX IF EXISTS idx_doc_memory_scopes_scope");
|
|
35
|
+
db.exec("DROP TABLE IF EXISTS doc_memory_scopes");
|
|
36
|
+
},
|
|
37
|
+
};
|
|
@@ -40,6 +40,7 @@ import { migration as m023 } from "./023-collection-egress-policy";
|
|
|
40
40
|
import { migration as m024 } from "./024-egress-derived-lineage";
|
|
41
41
|
import { migration as m025 } from "./025-collection-egress-policy-revision";
|
|
42
42
|
import { migration as m026 } from "./026-file-refactor-recovery-journal";
|
|
43
|
+
import { migration as m027 } from "./027-memory-scopes";
|
|
43
44
|
|
|
44
45
|
/** All migrations in order */
|
|
45
46
|
export const migrations = [
|
|
@@ -69,4 +70,5 @@ export const migrations = [
|
|
|
69
70
|
m024,
|
|
70
71
|
m025,
|
|
71
72
|
m026,
|
|
73
|
+
m027,
|
|
72
74
|
];
|
|
@@ -75,6 +75,8 @@ import type {
|
|
|
75
75
|
IndexStatus,
|
|
76
76
|
IngestErrorInput,
|
|
77
77
|
IngestErrorRow,
|
|
78
|
+
MemoryEligibleDocument,
|
|
79
|
+
MemoryEligibleDocumentsOptions,
|
|
78
80
|
MigrationResult,
|
|
79
81
|
RetrievalTraceAppendResult,
|
|
80
82
|
RetrievalTraceBundle,
|
|
@@ -324,6 +326,13 @@ type FtsQueryBuildResult =
|
|
|
324
326
|
| { ok: true; query: string }
|
|
325
327
|
| { ok: false; error: string };
|
|
326
328
|
|
|
329
|
+
/**
|
|
330
|
+
* SQL fragment excluding documents superseded by an active document via the
|
|
331
|
+
* typed `supersedes` edge. `docIdExpr` names the candidate document id column.
|
|
332
|
+
*/
|
|
333
|
+
const SUPERSEDED_EXCLUSION_SQL = (docIdExpr: string): string =>
|
|
334
|
+
`AND NOT EXISTS (SELECT 1 FROM doc_edges se JOIN documents sd ON sd.id = se.src_doc_id AND sd.active = 1 WHERE se.dst_doc_id = ${docIdExpr} AND se.edge_type = 'supersedes')`;
|
|
335
|
+
|
|
327
336
|
/**
|
|
328
337
|
* Narrow lexical grammar for BM25/FTS queries.
|
|
329
338
|
*
|
|
@@ -333,7 +342,10 @@ type FtsQueryBuildResult =
|
|
|
333
342
|
* - negation with at least one positive term
|
|
334
343
|
* - hyphenated compounds handled intentionally
|
|
335
344
|
*/
|
|
336
|
-
function buildFts5Query(
|
|
345
|
+
function buildFts5Query(
|
|
346
|
+
query: string,
|
|
347
|
+
options: { anyTerm?: boolean } = {}
|
|
348
|
+
): FtsQueryBuildResult {
|
|
337
349
|
const trimmed = query.trim();
|
|
338
350
|
if (!trimmed) {
|
|
339
351
|
return { ok: false, error: "Search query cannot be empty" };
|
|
@@ -454,7 +466,9 @@ function buildFts5Query(query: string): FtsQueryBuildResult {
|
|
|
454
466
|
};
|
|
455
467
|
}
|
|
456
468
|
|
|
457
|
-
let ftsQuery =
|
|
469
|
+
let ftsQuery = options.anyTerm
|
|
470
|
+
? `(${positive.join(" OR ")})`
|
|
471
|
+
: positive.join(" AND ");
|
|
458
472
|
for (const negation of negative) {
|
|
459
473
|
ftsQuery = `${ftsQuery} NOT ${negation}`;
|
|
460
474
|
}
|
|
@@ -2635,7 +2649,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
2635
2649
|
try {
|
|
2636
2650
|
const db = this.ensureOpen();
|
|
2637
2651
|
const limit = options.limit ?? 20;
|
|
2638
|
-
const builtQuery = buildFts5Query(query);
|
|
2652
|
+
const builtQuery = buildFts5Query(query, { anyTerm: options.anyTerm });
|
|
2639
2653
|
if (!builtQuery.ok) {
|
|
2640
2654
|
return err("INVALID_INPUT", builtQuery.error);
|
|
2641
2655
|
}
|
|
@@ -2683,6 +2697,21 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
2683
2697
|
params.push(`%${options.author.toLowerCase()}%`);
|
|
2684
2698
|
}
|
|
2685
2699
|
|
|
2700
|
+
// Scope and supersession filters run inside the candidate subquery so
|
|
2701
|
+
// they narrow the corpus before the FTS LIMIT (never a post-filter).
|
|
2702
|
+
const innerConditions: string[] = [];
|
|
2703
|
+
const innerParams: string[] = [];
|
|
2704
|
+
if (options.memoryScopesAny && options.memoryScopesAny.length > 0) {
|
|
2705
|
+
const placeholders = options.memoryScopesAny.map(() => "?").join(",");
|
|
2706
|
+
innerConditions.push(
|
|
2707
|
+
`AND EXISTS (SELECT 1 FROM doc_memory_scopes ms WHERE ms.document_id = documents.id AND ms.scope IN (${placeholders}))`
|
|
2708
|
+
);
|
|
2709
|
+
innerParams.push(...options.memoryScopesAny);
|
|
2710
|
+
}
|
|
2711
|
+
if (options.excludeSuperseded) {
|
|
2712
|
+
innerConditions.push(SUPERSEDED_EXCLUSION_SQL("documents.id"));
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2686
2715
|
const hasOuterFilters = tagConditions.length > 0;
|
|
2687
2716
|
const ftsLimit = hasOuterFilters ? limit * 10 : limit;
|
|
2688
2717
|
params.push(limit);
|
|
@@ -2711,6 +2740,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
2711
2740
|
? "AND (COALESCE(NULLIF(record_source_path, ''), rel_path) = ? OR substr(COALESCE(NULLIF(record_source_path, ''), rel_path), 1, length(?) + 1) = ? || '/')"
|
|
2712
2741
|
: ""
|
|
2713
2742
|
}
|
|
2743
|
+
${innerConditions.join("\n ")}
|
|
2714
2744
|
)
|
|
2715
2745
|
ORDER BY score
|
|
2716
2746
|
LIMIT ?
|
|
@@ -2789,6 +2819,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
2789
2819
|
options.relPathPrefix,
|
|
2790
2820
|
]
|
|
2791
2821
|
: []),
|
|
2822
|
+
...innerParams,
|
|
2792
2823
|
ftsLimit,
|
|
2793
2824
|
...params,
|
|
2794
2825
|
];
|
|
@@ -3076,6 +3107,99 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
3076
3107
|
}
|
|
3077
3108
|
}
|
|
3078
3109
|
|
|
3110
|
+
async setDocMemoryScopes(
|
|
3111
|
+
documentId: number,
|
|
3112
|
+
scopes: string[]
|
|
3113
|
+
): Promise<StoreResult<void>> {
|
|
3114
|
+
try {
|
|
3115
|
+
const db = this.ensureOpen();
|
|
3116
|
+
const transaction = db.transaction(() => {
|
|
3117
|
+
db.run("DELETE FROM doc_memory_scopes WHERE document_id = ?", [
|
|
3118
|
+
documentId,
|
|
3119
|
+
]);
|
|
3120
|
+
if (scopes.length > 0) {
|
|
3121
|
+
const stmt = db.prepare(
|
|
3122
|
+
"INSERT OR IGNORE INTO doc_memory_scopes (document_id, scope) VALUES (?, ?)"
|
|
3123
|
+
);
|
|
3124
|
+
for (const scope of scopes) {
|
|
3125
|
+
stmt.run(documentId, scope);
|
|
3126
|
+
}
|
|
3127
|
+
}
|
|
3128
|
+
});
|
|
3129
|
+
transaction();
|
|
3130
|
+
return ok(undefined);
|
|
3131
|
+
} catch (cause) {
|
|
3132
|
+
return err(
|
|
3133
|
+
"QUERY_FAILED",
|
|
3134
|
+
cause instanceof Error ? cause.message : "Failed to set memory scopes",
|
|
3135
|
+
cause
|
|
3136
|
+
);
|
|
3137
|
+
}
|
|
3138
|
+
}
|
|
3139
|
+
|
|
3140
|
+
async getDocMemoryScopes(documentId: number): Promise<StoreResult<string[]>> {
|
|
3141
|
+
try {
|
|
3142
|
+
const db = this.ensureOpen();
|
|
3143
|
+
const rows = db
|
|
3144
|
+
.query<{ scope: string }, [number]>(
|
|
3145
|
+
"SELECT scope FROM doc_memory_scopes WHERE document_id = ? ORDER BY scope"
|
|
3146
|
+
)
|
|
3147
|
+
.all(documentId);
|
|
3148
|
+
return ok(rows.map((row) => row.scope));
|
|
3149
|
+
} catch (cause) {
|
|
3150
|
+
return err(
|
|
3151
|
+
"QUERY_FAILED",
|
|
3152
|
+
cause instanceof Error ? cause.message : "Failed to read memory scopes",
|
|
3153
|
+
cause
|
|
3154
|
+
);
|
|
3155
|
+
}
|
|
3156
|
+
}
|
|
3157
|
+
|
|
3158
|
+
async listMemoryEligibleDocuments(
|
|
3159
|
+
options: MemoryEligibleDocumentsOptions
|
|
3160
|
+
): Promise<StoreResult<MemoryEligibleDocument[]>> {
|
|
3161
|
+
try {
|
|
3162
|
+
const db = this.ensureOpen();
|
|
3163
|
+
if (options.scopes.length === 0) {
|
|
3164
|
+
return ok([]);
|
|
3165
|
+
}
|
|
3166
|
+
const placeholders = options.scopes.map(() => "?").join(",");
|
|
3167
|
+
const rows = db
|
|
3168
|
+
.query<
|
|
3169
|
+
{ id: number; docid: string; uri: string; mirror_hash: string },
|
|
3170
|
+
string[]
|
|
3171
|
+
>(
|
|
3172
|
+
`
|
|
3173
|
+
SELECT d.id, d.docid, d.uri, d.mirror_hash
|
|
3174
|
+
FROM documents d
|
|
3175
|
+
WHERE d.active = 1
|
|
3176
|
+
AND d.collection = ?
|
|
3177
|
+
AND d.mirror_hash IS NOT NULL
|
|
3178
|
+
AND EXISTS (SELECT 1 FROM doc_memory_scopes ms WHERE ms.document_id = d.id AND ms.scope IN (${placeholders}))
|
|
3179
|
+
${options.excludeSuperseded ? SUPERSEDED_EXCLUSION_SQL("d.id") : ""}
|
|
3180
|
+
ORDER BY d.id
|
|
3181
|
+
`
|
|
3182
|
+
)
|
|
3183
|
+
.all(options.collection, ...options.scopes);
|
|
3184
|
+
return ok(
|
|
3185
|
+
rows.map((row) => ({
|
|
3186
|
+
id: row.id,
|
|
3187
|
+
docid: row.docid,
|
|
3188
|
+
uri: row.uri,
|
|
3189
|
+
mirrorHash: row.mirror_hash,
|
|
3190
|
+
}))
|
|
3191
|
+
);
|
|
3192
|
+
} catch (cause) {
|
|
3193
|
+
return err(
|
|
3194
|
+
"QUERY_FAILED",
|
|
3195
|
+
cause instanceof Error
|
|
3196
|
+
? cause.message
|
|
3197
|
+
: "Failed to list memory-eligible documents",
|
|
3198
|
+
cause
|
|
3199
|
+
);
|
|
3200
|
+
}
|
|
3201
|
+
}
|
|
3202
|
+
|
|
3079
3203
|
/**
|
|
3080
3204
|
* Get all tags for a document.
|
|
3081
3205
|
*/
|
package/src/store/types.ts
CHANGED
|
@@ -643,6 +643,35 @@ export interface FtsSearchOptions {
|
|
|
643
643
|
categories?: string[];
|
|
644
644
|
/** Filter by author field (case-insensitive contains) */
|
|
645
645
|
author?: string;
|
|
646
|
+
/**
|
|
647
|
+
* Managed-memory scope filter: keep only documents carrying at least one of
|
|
648
|
+
* these normalized scopes. Applied inside the FTS candidate subquery, before
|
|
649
|
+
* any LIMIT, so an out-of-scope document never occupies the window.
|
|
650
|
+
*/
|
|
651
|
+
memoryScopesAny?: string[];
|
|
652
|
+
/**
|
|
653
|
+
* Exclude documents that an active document supersedes (typed edge
|
|
654
|
+
* `supersedes` pointing at them). Applied inside the candidate subquery.
|
|
655
|
+
*/
|
|
656
|
+
excludeSuperseded?: boolean;
|
|
657
|
+
/** Match documents containing ANY positive term instead of ALL of them. */
|
|
658
|
+
anyTerm?: boolean;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/** Managed-memory eligibility query (unbounded, executed in one SQL query). */
|
|
662
|
+
export interface MemoryEligibleDocumentsOptions {
|
|
663
|
+
collection: string;
|
|
664
|
+
/** Normalized scopes; any-intersection semantics. */
|
|
665
|
+
scopes: string[];
|
|
666
|
+
excludeSuperseded?: boolean;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** Minimal identity of a memory record eligible for managed recall. */
|
|
670
|
+
export interface MemoryEligibleDocument {
|
|
671
|
+
id: number;
|
|
672
|
+
docid: string;
|
|
673
|
+
uri: string;
|
|
674
|
+
mirrorHash: string;
|
|
646
675
|
}
|
|
647
676
|
|
|
648
677
|
/** Single FTS search result */
|
|
@@ -2022,6 +2051,31 @@ export interface StorePort {
|
|
|
2022
2051
|
*/
|
|
2023
2052
|
getTagsForDoc(documentId: number): Promise<StoreResult<TagRow[]>>;
|
|
2024
2053
|
|
|
2054
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
2055
|
+
// Memory scopes (managed memory records)
|
|
2056
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
2057
|
+
|
|
2058
|
+
/**
|
|
2059
|
+
* Replace the indexed scope set of one document. An empty list clears it,
|
|
2060
|
+
* which removes the document from managed recall.
|
|
2061
|
+
*/
|
|
2062
|
+
setDocMemoryScopes(
|
|
2063
|
+
documentId: number,
|
|
2064
|
+
scopes: string[]
|
|
2065
|
+
): Promise<StoreResult<void>>;
|
|
2066
|
+
|
|
2067
|
+
/** Indexed scopes for one document (sorted). */
|
|
2068
|
+
getDocMemoryScopes(documentId: number): Promise<StoreResult<string[]>>;
|
|
2069
|
+
|
|
2070
|
+
/**
|
|
2071
|
+
* Every active document in the collection carrying at least one requested
|
|
2072
|
+
* scope, optionally excluding superseded records. One unbounded query, so
|
|
2073
|
+
* the result is the exact eligible set rather than a candidate window.
|
|
2074
|
+
*/
|
|
2075
|
+
listMemoryEligibleDocuments(
|
|
2076
|
+
options: MemoryEligibleDocumentsOptions
|
|
2077
|
+
): Promise<StoreResult<MemoryEligibleDocument[]>>;
|
|
2078
|
+
|
|
2025
2079
|
/**
|
|
2026
2080
|
* Get tags for multiple documents in a single query.
|
|
2027
2081
|
* Returns a map of documentId -> TagRow[].
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
82afff37d4a331933c19bebb9c73972fd6eb665ffd1444bbdf65cfb0220dd692 gno-browser-clipper-v1.39.2.zip
|