sentinelayer-cli 0.46.5 → 0.48.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 +47 -3
- package/package.json +12 -7
- package/skills/omar-gate-setup/SKILL.md +103 -0
- package/skills/senti-agent-identity/SKILL.md +80 -0
- package/skills/senti-judge/SKILL.md +139 -0
- package/skills/senti-room-notifications/SKILL.md +73 -0
- package/skills/senti-room-notifications/listen-wrapper.cjs +150 -0
- package/skills/senti-room-notifications/package.json +1 -0
- package/skills/senti-room-notifications/test-classifier.js +109 -0
- package/skills/senti-room-notifications/test-receipt.js +54 -0
- package/skills/sentinelayer-cli/SKILL.md +222 -0
- package/src/agents/devtestbot/runner.js +22 -11
- package/src/agents/devtestbot/tool.js +24 -11
- package/src/agents/jules/fix-cycle.js +7 -1
- package/src/agents/jules/tools/runtime-audit.js +17 -12
- package/src/agents/shared-tools/dispatch-core.js +10 -5
- package/src/ai/aidenid.js +4 -2
- package/src/ai/domain-target-store.js +11 -5
- package/src/ai/identity-store.js +11 -5
- package/src/ai/site-store.js +8 -4
- package/src/audit/orchestrator.js +21 -14
- package/src/audit/package.js +6 -4
- package/src/audit/persona-loop.js +7 -0
- package/src/audit/replay.js +4 -2
- package/src/auth/gate.js +32 -1
- package/src/auth/session-store.js +37 -0
- package/src/board/evidence-resolvers.js +3 -4
- package/src/cli.js +23 -3
- package/src/commands/ai/identity-lifecycle.js +30 -26
- package/src/commands/ai/provision-governance.js +32 -24
- package/src/commands/ai/shared.js +6 -4
- package/src/commands/audit.js +14 -12
- package/src/commands/board.js +2 -2
- package/src/commands/chat.js +8 -6
- package/src/commands/judge.js +125 -0
- package/src/commands/legacy-args.js +1 -0
- package/src/commands/mcp.js +45 -52
- package/src/commands/omargate.js +2 -1
- package/src/commands/plugin.js +11 -3
- package/src/commands/review.js +9 -4
- package/src/commands/scan.js +7 -7
- package/src/commands/session.js +516 -30
- package/src/commands/skills.js +45 -0
- package/src/commands/watch.js +9 -7
- package/src/config/output-filesystem.js +156 -0
- package/src/config/schema.js +4 -2
- package/src/config/service.js +40 -4
- package/src/cost/history.js +15 -9
- package/src/daemon/artifact-lineage.js +15 -12
- package/src/daemon/assignment-ledger.js +31 -25
- package/src/daemon/ast-drift.js +7 -3
- package/src/daemon/budget-governor.js +26 -20
- package/src/daemon/error-worker.js +28 -22
- package/src/daemon/fix-cycle.js +5 -4
- package/src/daemon/hybrid-mapper.js +19 -16
- package/src/daemon/ingest-refresh.js +4 -0
- package/src/daemon/jira-lifecycle.js +21 -17
- package/src/daemon/operator-control.js +18 -15
- package/src/daemon/reliability-lane.js +18 -15
- package/src/daemon/scope-engine.js +12 -8
- package/src/daemon/watchdog.js +15 -12
- package/src/engram/build-state.js +24 -3
- package/src/engram/capability-index.js +367 -0
- package/src/engram/compile-graph.js +1063 -0
- package/src/engram/contracts/action-receipt.js +82 -0
- package/src/engram/contracts/capability-index.js +259 -0
- package/src/engram/contracts/capability.js +377 -0
- package/src/engram/contracts/content-hash.js +8 -0
- package/src/engram/contracts/evidence.js +258 -0
- package/src/engram/contracts/graph.js +594 -0
- package/src/engram/contracts/identity.js +8 -0
- package/src/engram/contracts/index.js +149 -0
- package/src/engram/contracts/media-asset.js +85 -0
- package/src/engram/contracts/money.js +249 -0
- package/src/engram/contracts/source-snapshot.js +57 -0
- package/src/engram/contracts/task-context-pack.js +114 -0
- package/src/engram/contracts/temporal-segment.js +72 -0
- package/src/engram/contracts/versions.js +96 -0
- package/src/engram/extract-html.js +692 -0
- package/src/engram/graph-store.js +877 -0
- package/src/engram/index.js +98 -14
- package/src/engram/ingest-site.js +132 -0
- package/src/engram/live-state.js +1180 -0
- package/src/engram/namespace.js +34 -1
- package/src/engram/retrieve-site.js +753 -0
- package/src/engram/safe-reader.js +597 -0
- package/src/engram/source-intake.js +251 -0
- package/src/engram/store.js +227 -9
- package/src/ingest/engine.js +21 -28
- package/src/judge/verdict.js +305 -0
- package/src/legacy-cli.js +96 -19
- package/src/mcp/bridge-inputs.js +25 -4
- package/src/mcp/cli-command-tools.js +70 -0
- package/src/mcp/registry.js +17 -13
- package/src/mcp/session-stdio-server.js +297 -19
- package/src/memory/blackboard.js +5 -4
- package/src/net/gh-api-path.js +35 -9
- package/src/net/test-egress-guard.js +16 -4
- package/src/plugin/manifest.js +12 -8
- package/src/review/ai-review.js +110 -10
- package/src/review/investor-dd-devtestbot.js +17 -6
- package/src/review/investor-dd-orchestrator.js +21 -13
- package/src/review/local-review.js +26 -16
- package/src/review/omargate-cache.js +5 -5
- package/src/review/omargate-orchestrator.js +89 -17
- package/src/review/replay.js +9 -4
- package/src/review/report.js +22 -13
- package/src/review/typesafe-severity.js +201 -0
- package/src/session/admission-auth.js +34 -7
- package/src/session/admission.js +110 -15
- package/src/session/attachment-summary.js +77 -0
- package/src/session/edit-summary.js +19 -0
- package/src/session/invitations.js +65 -1
- package/src/session/mentions.js +17 -9
- package/src/session/message-edits.js +5 -4
- package/src/session/preview.js +10 -2
- package/src/session/recap.js +3 -2
- package/src/session/room-files.js +519 -0
- package/src/session/route-classes.js +22 -8
- package/src/session/setup-guides.js +4 -0
- package/src/session/standup.js +192 -0
- package/src/session/sync.js +229 -10
- package/src/session/tickets.js +41 -0
- package/src/session/transcript.js +2 -1
- package/src/session/wake/resolve-target.js +7 -3
- package/src/skills/listener-runtime.cjs +92 -0
- package/src/skills/service.js +207 -0
- package/src/swarm/factory.js +5 -5
- package/src/swarm/pentest.js +9 -9
- package/src/swarm/report.js +4 -4
- package/src/swarm/runtime.js +15 -12
- package/src/swarm/scenario-dsl.js +7 -5
- package/src/telemetry/ledger.js +16 -7
|
@@ -0,0 +1,753 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ENGRAM §EW06 — Hybrid site-entity retrieval.
|
|
3
|
+
*
|
|
4
|
+
* Retrieves relevant site entities from a compiled ENGRAM graph without
|
|
5
|
+
* sending the entire graph to a model. Reuses the §1 recall engine
|
|
6
|
+
* (dense int8 scan + BM25 + RRF + bounded graph diffusion) over
|
|
7
|
+
* entity/assertion "observations" built from the compiled graph.
|
|
8
|
+
*
|
|
9
|
+
* Mandatory constraints (per ticket 97996969):
|
|
10
|
+
* - Exact IDs and filters precede semantic ranking
|
|
11
|
+
* - Tenant/ACL filters applied BEFORE retrieval and rechecked before serving
|
|
12
|
+
* - Embedding model/dimensions pinned; incompatible vector spaces refused
|
|
13
|
+
* - No unbounded graph expansion
|
|
14
|
+
* - Stable cursor pagination
|
|
15
|
+
* - Explainable provenance evidence on every result
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { buildRecallIndex } from "../session/recall/index-core.js";
|
|
19
|
+
import { recall as recallEngine, RECALL_DEFAULTS } from "../session/recall/retrieve.js";
|
|
20
|
+
import { createEmbedder } from "../session/recall/embedder.js";
|
|
21
|
+
import { normalizeString, tokenize, clipSnippet } from "../session/recall/text.js";
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Pinned constants
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
/** Pinned retrieval index schema version — bump on breaking index changes. */
|
|
28
|
+
export const RETRIEVE_SITE_INDEX_VERSION = "1.0.0";
|
|
29
|
+
|
|
30
|
+
/** Required embedder dimension — refuse anything else (per constraint). */
|
|
31
|
+
export const REQUIRED_EMBEDDER_DIM = 256;
|
|
32
|
+
|
|
33
|
+
/** Default retrieval parameters (separate from session recall defaults). */
|
|
34
|
+
export const SITE_RETRIEVAL_DEFAULTS = Object.freeze({
|
|
35
|
+
k: 12,
|
|
36
|
+
expansionHops: 5, // tighter than session recall (8) — site graphs are larger
|
|
37
|
+
expansionDecay: 0.8,
|
|
38
|
+
hubDegreeCap: 30,
|
|
39
|
+
poolCap: 256,
|
|
40
|
+
pageSize: 20,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Helpers
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Build a generation-bound cursor for stable pagination.
|
|
49
|
+
* Format: base64url(json({g:<generation>,o:<offset>,q:<queryHash>}))
|
|
50
|
+
*
|
|
51
|
+
* The cursor binds to the graph generation so that a republish
|
|
52
|
+
* (which increments the generation) invalidates the cursor rather
|
|
53
|
+
* than silently restarting. The query hash ensures a cursor from
|
|
54
|
+
* a different query does not silently apply.
|
|
55
|
+
*/
|
|
56
|
+
function encodeCursor(generation, offset, queryHash) {
|
|
57
|
+
const payload = JSON.stringify({
|
|
58
|
+
v: String(generation || ""),
|
|
59
|
+
o: Number.isFinite(offset) ? offset : 0,
|
|
60
|
+
q: String(queryHash || ""),
|
|
61
|
+
});
|
|
62
|
+
return Buffer.from(payload, "utf-8").toString("base64url");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Decode a pagination cursor. Returns null for garbage input,
|
|
67
|
+
* and validates the generation/offset/queryHash fields.
|
|
68
|
+
*/
|
|
69
|
+
function decodeCursor(cursor) {
|
|
70
|
+
const raw = normalizeString(cursor);
|
|
71
|
+
if (!raw) return null;
|
|
72
|
+
try {
|
|
73
|
+
const json = Buffer.from(raw, "base64url").toString("utf-8");
|
|
74
|
+
const parsed = JSON.parse(json);
|
|
75
|
+
if (parsed && typeof parsed === "object" && typeof parsed.v === "string" && typeof parsed.o === "number" && typeof parsed.q === "string") {
|
|
76
|
+
return { version: parsed.v, offset: parsed.o, queryHash: parsed.q };
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Flatten graph assertions/attributes into searchable text for an entity.
|
|
86
|
+
*/
|
|
87
|
+
function entitySearchText(entity, assertionsForEntity) {
|
|
88
|
+
const parts = [];
|
|
89
|
+
if (entity.label) parts.push(entity.label);
|
|
90
|
+
if (entity.type && entity.type !== "unknown") parts.push(entity.type);
|
|
91
|
+
|
|
92
|
+
// Per-attribute assertions carry factual content (SKUs, prices, descriptions)
|
|
93
|
+
for (const assertion of assertionsForEntity) {
|
|
94
|
+
const pred = normalizeString(assertion.predicate);
|
|
95
|
+
if (pred && !["contains", "describes", "references", "variant_of", "same_as_candidate"].includes(pred)) {
|
|
96
|
+
parts.push(pred);
|
|
97
|
+
}
|
|
98
|
+
// Safely extract string values from any JSON-compatible assertion object.
|
|
99
|
+
const obj = assertion.object;
|
|
100
|
+
if (typeof obj === "string") {
|
|
101
|
+
parts.push(normalizeString(obj));
|
|
102
|
+
} else if (typeof obj === "number" || typeof obj === "boolean") {
|
|
103
|
+
parts.push(String(obj));
|
|
104
|
+
} else if (obj !== null && typeof obj === "object") {
|
|
105
|
+
if (Array.isArray(obj)) {
|
|
106
|
+
for (const item of obj) {
|
|
107
|
+
if (typeof item === "string") parts.push(normalizeString(item));
|
|
108
|
+
else if (typeof item === "number" || typeof item === "boolean") parts.push(String(item));
|
|
109
|
+
}
|
|
110
|
+
} else {
|
|
111
|
+
for (const val of Object.values(obj)) {
|
|
112
|
+
if (typeof val === "string") parts.push(normalizeString(val));
|
|
113
|
+
else if (typeof val === "number" || typeof val === "boolean") parts.push(String(val));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Entity attributes (SKUs, prices, etc.)
|
|
120
|
+
if (entity.attributes !== null && entity.attributes !== undefined && typeof entity.attributes === "object") {
|
|
121
|
+
// Use Object.entries so we skip prototype-inherited and __proto__ keys.
|
|
122
|
+
const safeEntries = Object.entries(Object(entity.attributes));
|
|
123
|
+
for (const [, val] of safeEntries) {
|
|
124
|
+
if (val === null || typeof val === "function") continue;
|
|
125
|
+
if (typeof val === "string") parts.push(normalizeString(val));
|
|
126
|
+
else if (typeof val === "number" || typeof val === "boolean") parts.push(String(val));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return parts.filter(Boolean).join(" · ");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Build an observation-like object from a graph entity for the recall index.
|
|
135
|
+
* Each entity becomes one observation, enriched with its assertions' text
|
|
136
|
+
* and with graph-link-derived entity references so the recall engine can
|
|
137
|
+
* build a proper entity graph for bounded spreading activation (mentions,
|
|
138
|
+
* files, prRefs from describes/contains/references links).
|
|
139
|
+
*/
|
|
140
|
+
function entityToObservation(entity, assertions, index) {
|
|
141
|
+
const text = entitySearchText(entity, assertions);
|
|
142
|
+
const displayText = text || [entity.label, entity.type].filter(Boolean).join(" ");
|
|
143
|
+
|
|
144
|
+
const mentions = [];
|
|
145
|
+
const files = [];
|
|
146
|
+
const prRefs = [];
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
id: entity.id,
|
|
150
|
+
sessionId: entity.tenantScope || "",
|
|
151
|
+
sequenceId: index + 1,
|
|
152
|
+
index,
|
|
153
|
+
ts: entity.capturedAt || new Date(0).toISOString(),
|
|
154
|
+
tsMs: Date.parse(entity.capturedAt) || 0,
|
|
155
|
+
kind: entity.type || "unknown",
|
|
156
|
+
agentId: "graph",
|
|
157
|
+
agentModel: "",
|
|
158
|
+
text: displayText,
|
|
159
|
+
tokens: tokenize(displayText),
|
|
160
|
+
mentions,
|
|
161
|
+
files,
|
|
162
|
+
prRefs,
|
|
163
|
+
replyToSequence: 0,
|
|
164
|
+
snippet: clipSnippet(displayText),
|
|
165
|
+
raw: { entity, assertions },
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Apply tenant/ACL filter to entities BEFORE building the index.
|
|
171
|
+
*/
|
|
172
|
+
function applyAccessFilter(entities, accessFilter) {
|
|
173
|
+
if (typeof accessFilter !== "function") return entities;
|
|
174
|
+
return entities.filter((entity) => {
|
|
175
|
+
try {
|
|
176
|
+
return accessFilter(entity) === true;
|
|
177
|
+
} catch {
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Check for exact entity-ID match in the query.
|
|
185
|
+
*
|
|
186
|
+
* Matches in order of precedence:
|
|
187
|
+
* 1. Exact entity.id match (full digest id).
|
|
188
|
+
* 2. Entity attribute value match (sku, gtin, productID, @id, etc.).
|
|
189
|
+
* 3. Attribute assertion value match (describes_sku, describes_gtin, etc.).
|
|
190
|
+
* 4. Hex digest prefix match (12+ hex chars matching id suffix).
|
|
191
|
+
*
|
|
192
|
+
* EW05 stores business identifiers in entity.attributes and emits
|
|
193
|
+
* describes_<attrKey> assertions, so queries for sku/gtin/productID
|
|
194
|
+
* values must take the exact path — not just fall through to BM25.
|
|
195
|
+
*/
|
|
196
|
+
const IDENTIFIER_KEYS = new Set([
|
|
197
|
+
"@id", "sku", "gtin", "gtin8", "gtin12", "gtin13", "gtin14",
|
|
198
|
+
"mpn", "productID", "identifier",
|
|
199
|
+
]);
|
|
200
|
+
|
|
201
|
+
const DESCRIBES_ID_KEYS = new Set(
|
|
202
|
+
Array.from(IDENTIFIER_KEYS).map((k) => `describes_${k}`),
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
function findExactEntityIdMatch(query, entities, assertionMap) {
|
|
206
|
+
const normalizedQuery = normalizeString(query);
|
|
207
|
+
if (!normalizedQuery) return [];
|
|
208
|
+
|
|
209
|
+
// 1. Exact id match first (case-insensitive).
|
|
210
|
+
for (const entity of entities) {
|
|
211
|
+
if (normalizeString(entity.id) === normalizedQuery) return [entity];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// 2. Match on identifier-key entity.attributes (case-insensitive).
|
|
215
|
+
const attrMatches = [];
|
|
216
|
+
for (const entity of entities) {
|
|
217
|
+
if (entity.attributes !== null && entity.attributes !== undefined && typeof entity.attributes === "object") {
|
|
218
|
+
for (const [key, val] of Object.entries(Object(entity.attributes))) {
|
|
219
|
+
if (!IDENTIFIER_KEYS.has(key)) continue;
|
|
220
|
+
if (typeof val === "string" && normalizeString(val).toLowerCase() === normalizedQuery.toLowerCase()) {
|
|
221
|
+
attrMatches.push(entity);
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
if (typeof val === "number" && String(val) === normalizedQuery.toLowerCase()) {
|
|
225
|
+
attrMatches.push(entity);
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
// Stable order by entity id.
|
|
232
|
+
attrMatches.sort((a, b) => normalizeString(a.id).localeCompare(normalizeString(b.id)));
|
|
233
|
+
if (attrMatches.length > 0) return attrMatches;
|
|
234
|
+
|
|
235
|
+
// 3. Match on describes_<identifierKey> assertions (case-insensitive).
|
|
236
|
+
const assertionMatches = [];
|
|
237
|
+
for (const entity of entities) {
|
|
238
|
+
const entityAssertions = assertionMap ? (assertionMap.get(entity.id) || []) : [];
|
|
239
|
+
for (const assertion of entityAssertions) {
|
|
240
|
+
if (!assertion || typeof assertion !== "object") continue;
|
|
241
|
+
const pred = normalizeString(assertion.predicate);
|
|
242
|
+
if (!pred || !DESCRIBES_ID_KEYS.has(pred)) continue;
|
|
243
|
+
const obj = assertion.object;
|
|
244
|
+
if (typeof obj === "string" && normalizeString(obj).toLowerCase() === normalizedQuery.toLowerCase()) {
|
|
245
|
+
assertionMatches.push(entity);
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
if (typeof obj === "number" && String(obj) === normalizedQuery.toLowerCase()) {
|
|
249
|
+
assertionMatches.push(entity);
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
assertionMatches.sort((a, b) => normalizeString(a.id).localeCompare(normalizeString(b.id)));
|
|
255
|
+
if (assertionMatches.length > 0) return assertionMatches;
|
|
256
|
+
|
|
257
|
+
// 4. Prefix match on the digest part (last 16 hex chars after last colon).
|
|
258
|
+
const digestMatch = normalizedQuery.match(/^[a-f0-9]{12,16}$/i);
|
|
259
|
+
if (digestMatch) {
|
|
260
|
+
const digest = digestMatch[0].toLowerCase();
|
|
261
|
+
for (const entity of entities) {
|
|
262
|
+
const lastColon = entity.id.lastIndexOf(":");
|
|
263
|
+
if (lastColon >= 0 && entity.id.slice(lastColon + 1).toLowerCase() === digest) {
|
|
264
|
+
return [entity];
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return [];
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
// Main retrieval
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Build a retrieval index over a compiled graph's entities.
|
|
278
|
+
*/
|
|
279
|
+
export function buildSiteRetrievalIndex({
|
|
280
|
+
graph,
|
|
281
|
+
embedder = createEmbedder(),
|
|
282
|
+
tenantScope = "",
|
|
283
|
+
accessFilter = null,
|
|
284
|
+
graphVersion,
|
|
285
|
+
} = {}) {
|
|
286
|
+
if (!graph || typeof graph !== "object") {
|
|
287
|
+
throw new TypeError("graph is required");
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Pin embedding dimensions.
|
|
291
|
+
if (embedder.dim !== REQUIRED_EMBEDDER_DIM) {
|
|
292
|
+
throw new Error(
|
|
293
|
+
`Embedder dimension mismatch: got ${embedder.dim}, required ${REQUIRED_EMBEDDER_DIM}. ` +
|
|
294
|
+
`Do not mix incompatible vector spaces.`,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const allEntities = Array.isArray(graph.entities) ? graph.entities : [];
|
|
299
|
+
const allAssertions = Array.isArray(graph.assertions) ? graph.assertions : [];
|
|
300
|
+
|
|
301
|
+
// Apply tenant/ACL filter BEFORE building index.
|
|
302
|
+
const entities = applyAccessFilter(allEntities, accessFilter);
|
|
303
|
+
const accessFiltered = allEntities.length - entities.length;
|
|
304
|
+
|
|
305
|
+
// Build assertion lookup by entity subject.
|
|
306
|
+
const assertionMap = new Map();
|
|
307
|
+
for (const assertion of allAssertions) {
|
|
308
|
+
if (!assertion || typeof assertion !== "object") continue;
|
|
309
|
+
const subject = normalizeString(assertion.subject);
|
|
310
|
+
if (!subject) continue;
|
|
311
|
+
if (!assertionMap.has(subject)) assertionMap.set(subject, []);
|
|
312
|
+
assertionMap.get(subject).push(assertion);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Build observations from entities.
|
|
316
|
+
const observations = entities.map((entity, i) => {
|
|
317
|
+
const entityAssertions = assertionMap.get(entity.id) || [];
|
|
318
|
+
return entityToObservation(entity, entityAssertions, i);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// Build the recall index.
|
|
322
|
+
const index = buildRecallIndex({
|
|
323
|
+
observations,
|
|
324
|
+
embedder,
|
|
325
|
+
sessionId: normalizeString(tenantScope),
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
// Build entity lookup for results.
|
|
329
|
+
const entityMap = new Map();
|
|
330
|
+
for (const entity of entities) {
|
|
331
|
+
entityMap.set(entity.id, entity);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return {
|
|
335
|
+
index,
|
|
336
|
+
indexSchemaVersion: RETRIEVE_SITE_INDEX_VERSION,
|
|
337
|
+
embedderName: embedder.name,
|
|
338
|
+
embedderDim: embedder.dim,
|
|
339
|
+
entityMap,
|
|
340
|
+
assertionMap,
|
|
341
|
+
entityCount: entities.length,
|
|
342
|
+
assertionCount: allAssertions.length,
|
|
343
|
+
accessFiltered,
|
|
344
|
+
graphVersion: String(graphVersion ?? ""),
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Retrieve site entities from a compiled graph.
|
|
350
|
+
*
|
|
351
|
+
* This is the main entry point. It builds an index from the graph, then
|
|
352
|
+
* queries it using the ENGRAM §1 recall engine (dense + BM25 + bounded
|
|
353
|
+
* graph diffusion). Exact entity-ID matches always rank first.
|
|
354
|
+
*/
|
|
355
|
+
/**
|
|
356
|
+
* Stable fingerprint of a query and its retrieval parameters.
|
|
357
|
+
* Used to bind cursors to a specific query so a cursor from a
|
|
358
|
+
* different query does not silently apply.
|
|
359
|
+
*/
|
|
360
|
+
function hashQueryFingerprint(normalizedQuery, k, tenantScope) {
|
|
361
|
+
// Simple deterministic hash — not cryptographic, just collision-resistant
|
|
362
|
+
// enough to bind a cursor to its originating query.
|
|
363
|
+
const input = `${normalizedQuery}|${k}|${String(tenantScope || "")}`;
|
|
364
|
+
let h = 0;
|
|
365
|
+
for (let i = 0; i < input.length; i++) {
|
|
366
|
+
h = ((h << 5) - h + input.charCodeAt(i)) | 0;
|
|
367
|
+
}
|
|
368
|
+
return String(h);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function retrieveSiteEntities({
|
|
372
|
+
graph,
|
|
373
|
+
query,
|
|
374
|
+
k = SITE_RETRIEVAL_DEFAULTS.k,
|
|
375
|
+
embedder = createEmbedder(),
|
|
376
|
+
tenantScope = "",
|
|
377
|
+
accessFilter = null,
|
|
378
|
+
cursor = "",
|
|
379
|
+
expansionHops = SITE_RETRIEVAL_DEFAULTS.expansionHops,
|
|
380
|
+
now = Date.now(),
|
|
381
|
+
graphVersion,
|
|
382
|
+
} = {}) {
|
|
383
|
+
const normalizedQuery = normalizeString(query);
|
|
384
|
+
if (normalizedQuery.length < 2) {
|
|
385
|
+
return { ok: false, reason: "query_too_short", results: [], nextCursor: null };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const normalizedK = Math.max(1, Math.floor(Number(k) || SITE_RETRIEVAL_DEFAULTS.k));
|
|
389
|
+
|
|
390
|
+
// Build the site retrieval index (includes access filtering).
|
|
391
|
+
const siteIndex = buildSiteRetrievalIndex({
|
|
392
|
+
graph,
|
|
393
|
+
embedder,
|
|
394
|
+
tenantScope,
|
|
395
|
+
accessFilter,
|
|
396
|
+
graphVersion,
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
const { index, entityMap } = siteIndex;
|
|
400
|
+
|
|
401
|
+
if (index.observations.length === 0) {
|
|
402
|
+
return {
|
|
403
|
+
ok: true,
|
|
404
|
+
reason: "no_entities_after_filtering",
|
|
405
|
+
results: [],
|
|
406
|
+
nextCursor: null,
|
|
407
|
+
meta: {
|
|
408
|
+
query: normalizedQuery,
|
|
409
|
+
k: normalizedK,
|
|
410
|
+
totalEntities: entityMap.size,
|
|
411
|
+
accessFiltered: siteIndex.accessFiltered,
|
|
412
|
+
embedderDim: embedder.dim,
|
|
413
|
+
},
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// --- Exact entity-ID match precedence ---
|
|
418
|
+
const allEntities = Array.from(entityMap.values());
|
|
419
|
+
const exactMatches = findExactEntityIdMatch(normalizedQuery, allEntities, siteIndex.assertionMap);
|
|
420
|
+
|
|
421
|
+
// --- Run the §1 recall engine ---
|
|
422
|
+
const recallConfig = {
|
|
423
|
+
...RECALL_DEFAULTS,
|
|
424
|
+
expansionHops: 0,
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
const engineResult = recallEngine(index, {
|
|
428
|
+
query: normalizedQuery,
|
|
429
|
+
k: index.observations.length, // fetch all for honest pagination
|
|
430
|
+
role: "",
|
|
431
|
+
now,
|
|
432
|
+
tuning: recallConfig,
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
// --- Assemble results with entity data ---
|
|
436
|
+
let results = engineResult.results.map((r) => {
|
|
437
|
+
const obs = index.byId.get(r.observationId);
|
|
438
|
+
const entity = obs?.raw?.entity || entityMap.get(r.observationId) || null;
|
|
439
|
+
const assertions = obs?.raw?.assertions || [];
|
|
440
|
+
|
|
441
|
+
// Recheck ACL before serving (per constraint).
|
|
442
|
+
let accessDenied = false;
|
|
443
|
+
if (typeof accessFilter === "function" && entity) {
|
|
444
|
+
try {
|
|
445
|
+
accessDenied = !accessFilter(entity);
|
|
446
|
+
} catch {
|
|
447
|
+
accessDenied = true;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (accessDenied) return null;
|
|
452
|
+
|
|
453
|
+
return {
|
|
454
|
+
entityId: entity?.id || r.observationId,
|
|
455
|
+
entityType: entity?.type || "unknown",
|
|
456
|
+
entityLabel: entity?.label || "",
|
|
457
|
+
score: r.score,
|
|
458
|
+
breakdown: r.breakdown,
|
|
459
|
+
snippet: r.snippet,
|
|
460
|
+
provenance: r.provenance,
|
|
461
|
+
assertionCount: assertions.length,
|
|
462
|
+
channels: r.breakdown?.channels || [],
|
|
463
|
+
entity: entity || null,
|
|
464
|
+
};
|
|
465
|
+
}).filter(Boolean);
|
|
466
|
+
|
|
467
|
+
// --- Explicit graph expansion: follow describes/contains/references links ---
|
|
468
|
+
// from top semantic hits (depth 1, max 50). This replaces the recall engine's
|
|
469
|
+
// built-in author-hub diffusion with targeted site-graph traversal.
|
|
470
|
+
// The recall engine runs with expansionHops=0 so only the explicit expansion
|
|
471
|
+
// below adds graph-derived results.
|
|
472
|
+
const EXPANSION_LINK_PREDICATES = new Set(["describes", "contains", "references"]);
|
|
473
|
+
const MAX_EXPANSION_RESULTS = 50;
|
|
474
|
+
|
|
475
|
+
// Build a map for fast link lookup: entityId -> linked entityIds with link info.
|
|
476
|
+
const forwardLinks = new Map(); // subject -> [{entityId, predicate}]
|
|
477
|
+
const reverseLinks = new Map(); // object -> [{entityId, predicate}]
|
|
478
|
+
const siteLinks = Array.isArray(graph.links) ? graph.links : [];
|
|
479
|
+
for (const link of siteLinks) {
|
|
480
|
+
if (!link || typeof link !== "object") continue;
|
|
481
|
+
const pred = normalizeString(link.predicate || link.type);
|
|
482
|
+
if (!pred || !EXPANSION_LINK_PREDICATES.has(pred)) continue;
|
|
483
|
+
const subj = normalizeString(link.subject);
|
|
484
|
+
const objEnt = link.object && typeof link.object === "object" ? link.object.entityId : null;
|
|
485
|
+
const objId = objEnt ? normalizeString(objEnt) : null;
|
|
486
|
+
if (subj && objId) {
|
|
487
|
+
if (!forwardLinks.has(subj)) forwardLinks.set(subj, []);
|
|
488
|
+
forwardLinks.get(subj).push({ entityId: objId, predicate: pred });
|
|
489
|
+
if (!reverseLinks.has(objId)) reverseLinks.set(objId, []);
|
|
490
|
+
reverseLinks.get(objId).push({ entityId: subj, predicate: pred });
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Also populate from assertions: describes/contains/references with entityId objects.
|
|
495
|
+
const allSiteAssertions = Array.isArray(graph.assertions) ? graph.assertions : [];
|
|
496
|
+
for (const a of allSiteAssertions) {
|
|
497
|
+
if (!a || typeof a !== "object") continue;
|
|
498
|
+
const pred = normalizeString(a.predicate);
|
|
499
|
+
if (!pred || !EXPANSION_LINK_PREDICATES.has(pred)) continue;
|
|
500
|
+
const subj = normalizeString(a.subject);
|
|
501
|
+
const objEnt = a.object && typeof a.object === "object" ? a.object.entityId : null;
|
|
502
|
+
const objId = objEnt ? normalizeString(objEnt) : null;
|
|
503
|
+
if (subj && objId) {
|
|
504
|
+
if (!forwardLinks.has(subj)) forwardLinks.set(subj, []);
|
|
505
|
+
forwardLinks.get(subj).push({ entityId: objId, predicate: pred });
|
|
506
|
+
if (!reverseLinks.has(objId)) reverseLinks.set(objId, []);
|
|
507
|
+
reverseLinks.get(objId).push({ entityId: subj, predicate: pred });
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Expand from each top semantic result only (not from expansion results).
|
|
512
|
+
// This prevents transitive expansion: entities found via expansion do not
|
|
513
|
+
// trigger further expansion, so depth-2 entities (like Zeta via Shop via Alpha)
|
|
514
|
+
// are excluded.
|
|
515
|
+
const expansionEntityIds = new Set();
|
|
516
|
+
const expansionProvenance = [];
|
|
517
|
+
const expansionLimit = Math.min(MAX_EXPANSION_RESULTS, results.length * 10);
|
|
518
|
+
const semanticResults = [...results]; // snapshot before expansion is appended
|
|
519
|
+
for (const r of semanticResults) {
|
|
520
|
+
if (expansionEntityIds.size >= expansionLimit) break;
|
|
521
|
+
const eId = r.entityId;
|
|
522
|
+
// Follow forward links (entity -> other).
|
|
523
|
+
const fwd = forwardLinks.get(eId) || [];
|
|
524
|
+
for (const link of fwd) {
|
|
525
|
+
if (expansionEntityIds.size >= expansionLimit) break;
|
|
526
|
+
if (entityMap.has(link.entityId) && !expansionEntityIds.has(link.entityId)) {
|
|
527
|
+
expansionEntityIds.add(link.entityId);
|
|
528
|
+
expansionProvenance.push({ sourceEntityId: eId, linkedEntityId: link.entityId, predicate: link.predicate, direction: "forward" });
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
// Follow reverse links (other -> entity).
|
|
532
|
+
const rev = reverseLinks.get(eId) || [];
|
|
533
|
+
for (const link of rev) {
|
|
534
|
+
if (expansionEntityIds.size >= expansionLimit) break;
|
|
535
|
+
if (entityMap.has(link.entityId) && !expansionEntityIds.has(link.entityId)) {
|
|
536
|
+
expansionEntityIds.add(link.entityId);
|
|
537
|
+
expansionProvenance.push({ sourceEntityId: eId, linkedEntityId: link.entityId, predicate: link.predicate, direction: "reverse" });
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// Build expansion results.
|
|
543
|
+
const expansionResults = [];
|
|
544
|
+
for (const eId of expansionEntityIds) {
|
|
545
|
+
const entity = entityMap.get(eId);
|
|
546
|
+
if (!entity) continue;
|
|
547
|
+
expansionResults.push({
|
|
548
|
+
entityId: entity.id,
|
|
549
|
+
entityType: entity.type || "unknown",
|
|
550
|
+
entityLabel: entity.label || "",
|
|
551
|
+
score: 0,
|
|
552
|
+
breakdown: {
|
|
553
|
+
cos: 0,
|
|
554
|
+
rrf: 0,
|
|
555
|
+
expansion: 1,
|
|
556
|
+
actr: 0,
|
|
557
|
+
channels: ["expansion"],
|
|
558
|
+
viaExpansionOnly: true,
|
|
559
|
+
},
|
|
560
|
+
snippet: clipSnippet(entity.label || entity.id),
|
|
561
|
+
provenance: expansionProvenance.filter((p) => p.linkedEntityId === eId).map((p) => ({
|
|
562
|
+
path: `expansion from ${p.sourceEntityId} via ${p.predicate} (${p.direction})`,
|
|
563
|
+
})),
|
|
564
|
+
assertionCount: (siteIndex.assertionMap.get(entity.id) || []).length,
|
|
565
|
+
channels: ["expansion"],
|
|
566
|
+
entity,
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Append expansion results after semantic results.
|
|
571
|
+
results = [...results, ...expansionResults];
|
|
572
|
+
|
|
573
|
+
// --- Exact-match precedence: move exact entity ID hits to the front ---
|
|
574
|
+
if (exactMatches.length > 0) {
|
|
575
|
+
const exactIds = new Set(exactMatches.map((e) => e.id));
|
|
576
|
+
results = results.filter((r) => !exactIds.has(r.entityId));
|
|
577
|
+
const exactResults = exactMatches.map((exactMatch) => ({
|
|
578
|
+
entityId: exactMatch.id,
|
|
579
|
+
entityType: exactMatch.type || "unknown",
|
|
580
|
+
entityLabel: exactMatch.label || "",
|
|
581
|
+
score: 1.0,
|
|
582
|
+
breakdown: {
|
|
583
|
+
cos: 0,
|
|
584
|
+
rrf: 0,
|
|
585
|
+
expansion: 0,
|
|
586
|
+
actr: 0,
|
|
587
|
+
channels: ["exact_id"],
|
|
588
|
+
viaExpansionOnly: false,
|
|
589
|
+
},
|
|
590
|
+
snippet: clipSnippet(exactMatch.label || exactMatch.id),
|
|
591
|
+
provenance: [{ path: `exact entity id: ${exactMatch.id}` }],
|
|
592
|
+
assertionCount: (siteIndex.assertionMap.get(exactMatch.id) || []).length,
|
|
593
|
+
channels: ["exact_id"],
|
|
594
|
+
entity: exactMatch,
|
|
595
|
+
}));
|
|
596
|
+
results = [...exactResults, ...results];
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// --- Generation-bound cursor pagination ---
|
|
600
|
+
const currentVersion = siteIndex.graphVersion;
|
|
601
|
+
const queryFingerprint = hashQueryFingerprint(normalizedQuery, normalizedK, tenantScope);
|
|
602
|
+
|
|
603
|
+
let cursorOffset = 0;
|
|
604
|
+
if (normalizeString(cursor)) {
|
|
605
|
+
const decodedCursor = decodeCursor(cursor);
|
|
606
|
+
if (!decodedCursor) {
|
|
607
|
+
// Garbage cursor — not valid base64url or missing required fields.
|
|
608
|
+
return {
|
|
609
|
+
ok: false,
|
|
610
|
+
reason: "cursor_invalid",
|
|
611
|
+
results: [],
|
|
612
|
+
nextCursor: null,
|
|
613
|
+
meta: { query: normalizedQuery, k: normalizedK },
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
if (String(decodedCursor.version) !== String(currentVersion)) {
|
|
617
|
+
// Stale cursor — graph was republished since this cursor was issued.
|
|
618
|
+
return {
|
|
619
|
+
ok: false,
|
|
620
|
+
reason: "cursor_stale",
|
|
621
|
+
results: [],
|
|
622
|
+
nextCursor: null,
|
|
623
|
+
meta: { query: normalizedQuery, k: normalizedK },
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
if (decodedCursor.queryHash !== queryFingerprint) {
|
|
627
|
+
// Cursor from a different query — refuse to apply.
|
|
628
|
+
return {
|
|
629
|
+
ok: false,
|
|
630
|
+
reason: "cursor_stale",
|
|
631
|
+
results: [],
|
|
632
|
+
nextCursor: null,
|
|
633
|
+
meta: { query: normalizedQuery, k: normalizedK },
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
cursorOffset = decodedCursor.offset;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const pageSize = SITE_RETRIEVAL_DEFAULTS.pageSize;
|
|
640
|
+
const paged = results.slice(cursorOffset, cursorOffset + Math.min(normalizedK, pageSize));
|
|
641
|
+
const hasMore = cursorOffset + paged.length < results.length;
|
|
642
|
+
const nextCursor = hasMore && paged.length > 0
|
|
643
|
+
? encodeCursor(currentVersion, cursorOffset + paged.length, queryFingerprint)
|
|
644
|
+
: null;
|
|
645
|
+
|
|
646
|
+
return {
|
|
647
|
+
ok: true,
|
|
648
|
+
reason: "",
|
|
649
|
+
query: normalizedQuery,
|
|
650
|
+
k: normalizedK,
|
|
651
|
+
results: paged,
|
|
652
|
+
totalMatched: results.length,
|
|
653
|
+
pageSize: paged.length,
|
|
654
|
+
nextCursor,
|
|
655
|
+
hasMore,
|
|
656
|
+
exactMatchFound: exactMatches.length > 0,
|
|
657
|
+
meta: {
|
|
658
|
+
totalEntities: entityMap.size,
|
|
659
|
+
accessFiltered: siteIndex.accessFiltered,
|
|
660
|
+
poolSize: engineResult.poolSize,
|
|
661
|
+
candidatePoolIds: engineResult.candidatePoolIds?.length || 0,
|
|
662
|
+
embedderName: embedder.name,
|
|
663
|
+
embedderDim: embedder.dim,
|
|
664
|
+
indexSchemaVersion: RETRIEVE_SITE_INDEX_VERSION,
|
|
665
|
+
graphVersion: currentVersion,
|
|
666
|
+
},
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Convenience: retrieve from a graph-store's current graph.
|
|
672
|
+
*/
|
|
673
|
+
export async function retrieveFromGraphStore({
|
|
674
|
+
graphStore,
|
|
675
|
+
caller,
|
|
676
|
+
namespace,
|
|
677
|
+
query,
|
|
678
|
+
k,
|
|
679
|
+
embedder,
|
|
680
|
+
tenantScope,
|
|
681
|
+
accessFilter,
|
|
682
|
+
cursor,
|
|
683
|
+
expansionHops,
|
|
684
|
+
now,
|
|
685
|
+
} = {}) {
|
|
686
|
+
if (!graphStore || typeof graphStore.readCurrentGraph !== "function") {
|
|
687
|
+
return { ok: false, reason: "invalid_graph_store", results: [], nextCursor: null };
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
let graphRecord;
|
|
691
|
+
try {
|
|
692
|
+
graphRecord = await graphStore.readCurrentGraph(caller, namespace);
|
|
693
|
+
} catch (error) {
|
|
694
|
+
const msg = normalizeString(error?.message || error);
|
|
695
|
+
// Distinguish access-denied from other errors.
|
|
696
|
+
const isAccessDenied = error?.name === "AccessDeniedError" ||
|
|
697
|
+
error?.code === "access_denied" ||
|
|
698
|
+
(msg && (msg.includes("access") || msg.includes("authoriz") || msg.includes("permit") || msg.includes("forbidden") || msg.includes("denied")));
|
|
699
|
+
if (isAccessDenied) {
|
|
700
|
+
return { ok: false, reason: "forbidden", results: [], nextCursor: null };
|
|
701
|
+
}
|
|
702
|
+
return {
|
|
703
|
+
ok: false,
|
|
704
|
+
reason: `graph_read_error: ${msg}`,
|
|
705
|
+
results: [],
|
|
706
|
+
nextCursor: null,
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
if (!graphRecord || !graphRecord.graph) {
|
|
711
|
+
return { ok: false, reason: "no_published_graph", results: [], nextCursor: null };
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// Run retrieval.
|
|
715
|
+
const result = await retrieveSiteEntities({
|
|
716
|
+
graph: graphRecord.graph,
|
|
717
|
+
graphVersion: graphRecord.version,
|
|
718
|
+
query,
|
|
719
|
+
k,
|
|
720
|
+
embedder,
|
|
721
|
+
tenantScope,
|
|
722
|
+
accessFilter,
|
|
723
|
+
cursor,
|
|
724
|
+
expansionHops,
|
|
725
|
+
now,
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
// Re-authorization: after ranking, re-check that the caller still has
|
|
729
|
+
// read access and the graph version hasn't changed. This catches access
|
|
730
|
+
// revocation and concurrent publications between read and serve.
|
|
731
|
+
if (graphStore && typeof graphStore.listGraphVersions === "function") {
|
|
732
|
+
try {
|
|
733
|
+
const versions = await graphStore.listGraphVersions(caller, namespace);
|
|
734
|
+
const latestVersion = Array.isArray(versions) && versions.length > 0
|
|
735
|
+
? versions[versions.length - 1].version
|
|
736
|
+
: null;
|
|
737
|
+
if (latestVersion !== null && latestVersion !== graphRecord.version) {
|
|
738
|
+
return {
|
|
739
|
+
ok: false,
|
|
740
|
+
reason: "generation_changed",
|
|
741
|
+
results: [],
|
|
742
|
+
nextCursor: null,
|
|
743
|
+
meta: result.meta,
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
} catch {
|
|
747
|
+
// Re-authorization failed — treat as forbidden.
|
|
748
|
+
return { ok: false, reason: "forbidden", results: [], nextCursor: null };
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
return result;
|
|
753
|
+
}
|