@fortemi/core 2026.6.9 → 2026.7.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 CHANGED
@@ -178,6 +178,55 @@ Unfiltered browse requests fetch only the part files intersecting the requested
178
178
  results, but the controller keeps only a bounded part cache and never sets a
179
179
  materialized full export in `getIndex()`.
180
180
 
181
+ AIWG records can use project-specific string types such as `aiwg.skill`,
182
+ `aiwg.command`, `aiwg.rule`, `aiwg.requirement`, and `research.ref`. Validation
183
+ still enforces required record fields, but it does not require unknown AIWG,
184
+ research, or documentation domains to collapse into `aiwg.artifact`.
185
+
186
+ For AIWG command-palette parity, opt into discovery ranking instead of the
187
+ default deterministic substring lookup:
188
+
189
+ ```ts
190
+ const ranked = controller.query('address the open issues', {
191
+ searchProfile: 'aiwg-discovery',
192
+ rank: true,
193
+ includeMatches: true,
194
+ })
195
+ ```
196
+
197
+ The discovery profile normalizes hyphen/space variants, strips common stopwords,
198
+ boosts trigger and capability facets, and returns match reasons for UI debugging.
199
+
200
+ Chunked indexes also support graph navigation without loading a full export:
201
+
202
+ ```ts
203
+ const neighbors = await controller.neighbors('aiwg:requirement:search', {
204
+ direction: 'both',
205
+ relationshipType: 'cites',
206
+ })
207
+
208
+ const graph = await controller.toCommunityGraphChunked()
209
+ ```
210
+
211
+ When scan parts are projected, relationship traversal scans the parts and
212
+ lazy-loads detail records only when relationships are not present in the scan
213
+ projection.
214
+
215
+ Static semantic search uses an optional sidecar contract:
216
+
217
+ ```ts
218
+ import { queryAiwgHybridIndex } from '@fortemi/core/aiwg-index'
219
+
220
+ const embeddingSet = await fetch('/search/aiwg-index/embeddings.json').then((res) => res.json())
221
+ const queryEmbedding = await hostEmbed('workspace health check')
222
+ const semanticResults = queryAiwgHybridIndex(index, embeddingSet, 'health check', queryEmbedding)
223
+ ```
224
+
225
+ `aiwg.fortemi.embedding.set.v1` records the model, dimensions, granularity
226
+ (`title-summary`, `body`, `chunked-body`, or a project value), source record IDs,
227
+ and per-vector input hashes. Hosts provide query embeddings; `@fortemi/core` does
228
+ not add a model runtime dependency for static AIWG search.
229
+
181
230
  ## What You Get
182
231
 
183
232
  | Surface | Description |
@@ -1,4 +1,5 @@
1
- type AiwgFortemiRecordType = 'crm.contact' | 'crm.organization' | 'crm.event' | 'crm.interaction' | 'aiwg.artifact' | 'docs.page';
1
+ type AiwgFortemiKnownRecordType = 'crm.contact' | 'crm.organization' | 'crm.event' | 'crm.interaction' | 'aiwg.artifact' | 'docs.page';
2
+ type AiwgFortemiRecordType = AiwgFortemiKnownRecordType | `aiwg.${string}` | `research.${string}` | `docs.${string}` | string;
2
3
  type AiwgPrivacyClassification = 'private' | 'sanitized' | 'public';
3
4
  type AiwgProvenanceConfidence = 'source' | 'candidate' | 'reviewed' | 'rejected';
4
5
  type AiwgReviewAction = 'accept' | 'reject' | 'defer';
@@ -118,7 +119,7 @@ interface AiwgFortemiChunkPart {
118
119
  interface AiwgIndexValidationResult {
119
120
  valid: boolean;
120
121
  errors: string[];
121
- counts: Partial<Record<AiwgFortemiRecordType, number>>;
122
+ counts: Partial<Record<string, number>>;
122
123
  }
123
124
  interface AiwgChunkedIndexValidationResult {
124
125
  valid: boolean;
@@ -138,16 +139,22 @@ interface AiwgIndexQueryOptions {
138
139
  snippetLength?: number;
139
140
  weights?: Partial<AiwgIndexQueryWeights>;
140
141
  includeMatches?: boolean;
142
+ searchProfile?: 'default' | 'aiwg-discovery';
141
143
  }
142
144
  interface AiwgIndexQueryWeights {
143
145
  title: number;
144
146
  text: number;
145
147
  tag: number;
146
148
  concept: number;
149
+ facet: number;
150
+ id: number;
151
+ source: number;
147
152
  }
148
153
  interface AiwgIndexQueryMatch {
149
- field: 'title' | 'text' | 'tag' | 'concept';
154
+ field: 'title' | 'text' | 'tag' | 'concept' | 'facet' | 'id' | 'source';
150
155
  value: string;
156
+ score?: number;
157
+ reason?: string;
151
158
  }
152
159
  interface AiwgIndexQueryRankedItem {
153
160
  item: AiwgFortemiRecord;
@@ -203,6 +210,83 @@ interface AiwgIndexGraphOptions {
203
210
  relationshipWeights?: Record<string, number>;
204
211
  includeDanglingRelationships?: boolean;
205
212
  }
213
+ type AiwgRelationshipDirection = 'in' | 'out' | 'both';
214
+ type AiwgRelationshipSetOperation = 'intersection' | 'union' | 'difference';
215
+ interface AiwgRelationshipTraversalOptions {
216
+ direction?: AiwgRelationshipDirection;
217
+ relationshipType?: string;
218
+ limit?: number;
219
+ }
220
+ interface AiwgRelationshipQueryOptions extends AiwgRelationshipTraversalOptions {
221
+ sourceId?: string;
222
+ targetId?: string;
223
+ type?: string;
224
+ }
225
+ interface AiwgRelationshipEdgeSummary {
226
+ source_id: string;
227
+ target_id: string;
228
+ type: string;
229
+ source_path?: string;
230
+ }
231
+ interface AiwgRelationshipNodeSummary {
232
+ id: string;
233
+ type: AiwgFortemiRecordType;
234
+ title: string;
235
+ }
236
+ interface AiwgRelationshipTraversalResult {
237
+ nodes: AiwgRelationshipNodeSummary[];
238
+ edges: AiwgRelationshipEdgeSummary[];
239
+ complete: boolean;
240
+ scannedParts?: number;
241
+ fetchedParts?: number;
242
+ }
243
+ interface AiwgRelationshipSetOptions extends AiwgRelationshipTraversalOptions {
244
+ op: AiwgRelationshipSetOperation;
245
+ a: string;
246
+ b: string;
247
+ }
248
+ interface AiwgRelationshipSetResult {
249
+ ids: string[];
250
+ op: AiwgRelationshipSetOperation;
251
+ }
252
+ interface AiwgStaticEmbeddingRecord {
253
+ record_id: string;
254
+ embedding: number[];
255
+ embedding_id?: string;
256
+ granularity?: string;
257
+ input_hash: string;
258
+ source_path?: string;
259
+ }
260
+ interface AiwgStaticEmbeddingSet {
261
+ schema_version: 'aiwg.fortemi.embedding.set.v1';
262
+ id: string;
263
+ model: string;
264
+ dimensions: number;
265
+ generated_at: string;
266
+ granularity: 'title-summary' | 'body' | 'chunked-body' | string;
267
+ metric?: 'cosine' | 'dot' | 'euclidean';
268
+ input_hash_algorithm?: string;
269
+ embeddings: AiwgStaticEmbeddingRecord[];
270
+ }
271
+ interface AiwgStaticSemanticQueryOptions {
272
+ limit?: number;
273
+ offset?: number;
274
+ minScore?: number;
275
+ }
276
+ interface AiwgStaticSemanticResult {
277
+ item: AiwgFortemiRecord;
278
+ score: number;
279
+ embedding?: AiwgStaticEmbeddingRecord;
280
+ }
281
+ interface AiwgStaticHybridQueryOptions extends AiwgStaticSemanticQueryOptions, AiwgIndexQueryOptions {
282
+ lexicalWeight?: number;
283
+ semanticWeight?: number;
284
+ }
285
+ interface AiwgStaticDuplicatePair {
286
+ left: AiwgFortemiRecord;
287
+ right: AiwgFortemiRecord;
288
+ score: number;
289
+ }
206
290
  interface AiwgReviewInput {
207
291
  item_id: string;
208
292
  action: AiwgReviewAction;
@@ -229,8 +313,14 @@ interface AiwgIndexController {
229
313
  query(query?: string, options?: AiwgIndexQueryOptions): AiwgIndexQueryResult;
230
314
  queryChunked(query?: string, options?: AiwgChunkedIndexQueryOptions): Promise<AiwgChunkedIndexQueryResult>;
231
315
  getRecord(id: string): Promise<AiwgFortemiRecord>;
316
+ neighbors(id: string, options?: AiwgRelationshipTraversalOptions): Promise<AiwgRelationshipTraversalResult>;
317
+ relationshipQuery(options?: AiwgRelationshipQueryOptions): Promise<AiwgRelationshipTraversalResult>;
318
+ relationshipSet(options: AiwgRelationshipSetOptions): Promise<AiwgRelationshipSetResult>;
232
319
  clearChunkCache(): void;
233
320
  toCommunityGraph(options?: AiwgIndexGraphOptions): ReturnType<typeof aiwgFortemiIndexToCommunityGraph>;
321
+ toCommunityGraphChunked(options?: AiwgIndexGraphOptions & {
322
+ onProgress?: (progress: AiwgChunkedIndexProgress) => void;
323
+ }): Promise<ReturnType<typeof aiwgFortemiIndexToCommunityGraph>>;
234
324
  setReviewDecision(input: AiwgReviewInput): AiwgReviewDecision;
235
325
  clearReviewDecision(itemId: string): void;
236
326
  createReviewDecisionExport(generatedAt?: string): AiwgReviewDecisionExport;
@@ -268,6 +358,11 @@ interface AiwgChunkedIndexBuildResult {
268
358
  }
269
359
  declare function buildAiwgChunkedIndex(index: AiwgFortemiIndexExport, options?: AiwgChunkedIndexBuildOptions): AiwgChunkedIndexBuildResult;
270
360
  declare function queryAiwgFortemiIndex(index: AiwgFortemiIndexExport, query?: string, options?: AiwgIndexQueryOptions): AiwgIndexQueryResult;
361
+ declare function validateAiwgStaticEmbeddingSet(value: unknown): AiwgChunkedIndexValidationResult;
362
+ declare function assertAiwgStaticEmbeddingSet(value: unknown): AiwgStaticEmbeddingSet;
363
+ declare function queryAiwgSemanticIndex(index: AiwgFortemiIndexExport, embeddingSet: AiwgStaticEmbeddingSet, queryEmbedding: number[], options?: AiwgStaticSemanticQueryOptions): AiwgStaticSemanticResult[];
364
+ declare function queryAiwgHybridIndex(index: AiwgFortemiIndexExport, embeddingSet: AiwgStaticEmbeddingSet, query: string, queryEmbedding: number[], options?: AiwgStaticHybridQueryOptions): AiwgStaticSemanticResult[];
365
+ declare function findAiwgStaticDuplicatePairs(index: AiwgFortemiIndexExport, embeddingSet: AiwgStaticEmbeddingSet, threshold?: number): AiwgStaticDuplicatePair[];
271
366
  declare function createAiwgReviewDecisionExport(source: Pick<AiwgFortemiIndexExport, 'schema_version'>, decisions: AiwgReviewDecision[], generatedAt?: string): AiwgReviewDecisionExport;
272
367
  declare function createAiwgIndexController(initialIndex?: AiwgFortemiIndexExport): AiwgIndexController;
273
368
  declare function aiwgFortemiIndexToCommunityGraph(index: AiwgFortemiIndexExport, options?: AiwgIndexGraphOptions): {
@@ -286,4 +381,4 @@ declare function aiwgFortemiIndexToCommunityGraph(index: AiwgFortemiIndexExport,
286
381
  }[];
287
382
  };
288
383
 
289
- export { AIWG_SCAN_REQUIRED_FIELDS, type AiwgChunkedIndexBuildOptions, type AiwgChunkedIndexBuildResult, type AiwgChunkedIndexDetailLoader, type AiwgChunkedIndexLoadOptions, type AiwgChunkedIndexLoader, type AiwgChunkedIndexProgress, type AiwgChunkedIndexProgressPhase, type AiwgChunkedIndexQueryOptions, type AiwgChunkedIndexQueryResult, type AiwgChunkedIndexValidationResult, type AiwgDetailIdEncoding, type AiwgFortemiChunkDetailRef, type AiwgFortemiChunkManifest, type AiwgFortemiChunkPart, type AiwgFortemiChunkPartRef, type AiwgFortemiIndexExport, type AiwgFortemiProjectedRecord, type AiwgFortemiProvenance, type AiwgFortemiProvenanceEvent, type AiwgFortemiRecord, type AiwgFortemiRecordSource, type AiwgFortemiRecordType, type AiwgFortemiRelationship, type AiwgFortemiSkosConcept, type AiwgFortemiSkosRelation, type AiwgFortemiSkosRelationType, type AiwgIndexController, type AiwgIndexControllerListener, type AiwgIndexControllerSnapshot, type AiwgIndexGraphOptions, type AiwgIndexQueryMatch, type AiwgIndexQueryOptions, type AiwgIndexQueryRankedItem, type AiwgIndexQueryResult, type AiwgIndexQueryWeights, type AiwgIndexValidationResult, type AiwgPrivacyClassification, type AiwgProvenanceConfidence, type AiwgReviewAction, type AiwgReviewDecision, type AiwgReviewDecisionExport, type AiwgReviewInput, aiwgDetailHrefForId, aiwgFortemiIndexToCommunityGraph, assertAiwgFortemiChunkManifest, assertAiwgFortemiChunkPart, assertAiwgFortemiIndexExport, buildAiwgChunkedIndex, createAiwgFetchChunkLoader, createAiwgFetchDetailLoader, createAiwgIndexController, createAiwgReviewDecisionExport, encodeAiwgDetailId, getAiwgFortemiFacets, queryAiwgFortemiIndex, validateAiwgFortemiChunkManifest, validateAiwgFortemiChunkPart, validateAiwgFortemiIndexExport };
384
+ export { AIWG_SCAN_REQUIRED_FIELDS, type AiwgChunkedIndexBuildOptions, type AiwgChunkedIndexBuildResult, type AiwgChunkedIndexDetailLoader, type AiwgChunkedIndexLoadOptions, type AiwgChunkedIndexLoader, type AiwgChunkedIndexProgress, type AiwgChunkedIndexProgressPhase, type AiwgChunkedIndexQueryOptions, type AiwgChunkedIndexQueryResult, type AiwgChunkedIndexValidationResult, type AiwgDetailIdEncoding, type AiwgFortemiChunkDetailRef, type AiwgFortemiChunkManifest, type AiwgFortemiChunkPart, type AiwgFortemiChunkPartRef, type AiwgFortemiIndexExport, type AiwgFortemiKnownRecordType, type AiwgFortemiProjectedRecord, type AiwgFortemiProvenance, type AiwgFortemiProvenanceEvent, type AiwgFortemiRecord, type AiwgFortemiRecordSource, type AiwgFortemiRecordType, type AiwgFortemiRelationship, type AiwgFortemiSkosConcept, type AiwgFortemiSkosRelation, type AiwgFortemiSkosRelationType, type AiwgIndexController, type AiwgIndexControllerListener, type AiwgIndexControllerSnapshot, type AiwgIndexGraphOptions, type AiwgIndexQueryMatch, type AiwgIndexQueryOptions, type AiwgIndexQueryRankedItem, type AiwgIndexQueryResult, type AiwgIndexQueryWeights, type AiwgIndexValidationResult, type AiwgPrivacyClassification, type AiwgProvenanceConfidence, type AiwgRelationshipDirection, type AiwgRelationshipEdgeSummary, type AiwgRelationshipNodeSummary, type AiwgRelationshipQueryOptions, type AiwgRelationshipSetOperation, type AiwgRelationshipSetOptions, type AiwgRelationshipSetResult, type AiwgRelationshipTraversalOptions, type AiwgRelationshipTraversalResult, type AiwgReviewAction, type AiwgReviewDecision, type AiwgReviewDecisionExport, type AiwgReviewInput, type AiwgStaticDuplicatePair, type AiwgStaticEmbeddingRecord, type AiwgStaticEmbeddingSet, type AiwgStaticHybridQueryOptions, type AiwgStaticSemanticQueryOptions, type AiwgStaticSemanticResult, aiwgDetailHrefForId, aiwgFortemiIndexToCommunityGraph, assertAiwgFortemiChunkManifest, assertAiwgFortemiChunkPart, assertAiwgFortemiIndexExport, assertAiwgStaticEmbeddingSet, buildAiwgChunkedIndex, createAiwgFetchChunkLoader, createAiwgFetchDetailLoader, createAiwgIndexController, createAiwgReviewDecisionExport, encodeAiwgDetailId, findAiwgStaticDuplicatePairs, getAiwgFortemiFacets, queryAiwgFortemiIndex, queryAiwgHybridIndex, queryAiwgSemanticIndex, validateAiwgFortemiChunkManifest, validateAiwgFortemiChunkPart, validateAiwgFortemiIndexExport, validateAiwgStaticEmbeddingSet };
@@ -25,19 +25,14 @@ var REQUIRED_RECORD_FIELDS = [
25
25
  "privacy",
26
26
  "updated_at"
27
27
  ];
28
- var VALID_TYPES = /* @__PURE__ */ new Set([
29
- "crm.contact",
30
- "crm.organization",
31
- "crm.event",
32
- "crm.interaction",
33
- "aiwg.artifact",
34
- "docs.page"
35
- ]);
36
28
  var DEFAULT_QUERY_WEIGHTS = {
37
29
  title: 4,
38
30
  tag: 3,
39
31
  concept: 2,
40
- text: 1
32
+ text: 1,
33
+ facet: 2,
34
+ id: 1,
35
+ source: 0.25
41
36
  };
42
37
  function hasString(value) {
43
38
  return typeof value === "string" && value.length > 0;
@@ -138,7 +133,7 @@ function validateAiwgFortemiIndexExport(value) {
138
133
  errors.push("items must be sorted by id: " + previousId + " before " + item.id);
139
134
  }
140
135
  if (hasString(item.id)) previousId = item.id;
141
- if (!VALID_TYPES.has(item.type)) errors.push("items[" + index + "].type is invalid");
136
+ if (!hasString(item.type)) errors.push("items[" + index + "].type must be a non-empty string");
142
137
  else counts[item.type] = (counts[item.type] ?? 0) + 1;
143
138
  if (!hasString(item.source?.path)) errors.push("items[" + index + "].source.path is required");
144
139
  if (!hasString(item.source?.repo_relative_path)) errors.push("items[" + index + "].source.repo_relative_path is required");
@@ -233,7 +228,7 @@ function validateProjectedRecords(items) {
233
228
  errors.push("items must be sorted by id: " + previousId + " before " + item.id);
234
229
  }
235
230
  if (hasString(item.id)) previousId = item.id;
236
- if (!item.type || !VALID_TYPES.has(item.type)) errors.push("items[" + index + "].type is invalid");
231
+ if (!hasString(item.type)) errors.push("items[" + index + "].type must be a non-empty string");
237
232
  if (!hasString(item.title)) errors.push("items[" + index + "].title is required");
238
233
  if (typeof item.text !== "string") errors.push("items[" + index + "].text is required");
239
234
  if (!item.facets || typeof item.facets !== "object" || Array.isArray(item.facets)) {
@@ -398,8 +393,99 @@ function queryMatches(item, q) {
398
393
  }
399
394
  return matches;
400
395
  }
396
+ var DISCOVERY_STOPWORDS = /* @__PURE__ */ new Set([
397
+ "a",
398
+ "an",
399
+ "and",
400
+ "are",
401
+ "as",
402
+ "for",
403
+ "from",
404
+ "how",
405
+ "i",
406
+ "in",
407
+ "is",
408
+ "me",
409
+ "of",
410
+ "on",
411
+ "or",
412
+ "please",
413
+ "the",
414
+ "to",
415
+ "use",
416
+ "with"
417
+ ]);
418
+ function normalizeDiscoveryText(value) {
419
+ return value.toLowerCase().replace(/[_/]+/g, " ").replace(/[^a-z0-9.-]+/g, " ").trim();
420
+ }
421
+ function canonicalDiscoveryName(value) {
422
+ return normalizeDiscoveryText(value).replace(/[\s.-]+/g, "");
423
+ }
424
+ function discoveryTokens(value) {
425
+ return normalizeDiscoveryText(value).split(/\s+/).filter((token) => token.length > 1 && !DISCOVERY_STOPWORDS.has(token));
426
+ }
427
+ function facetValues(item, names) {
428
+ return names.flatMap((name) => item.facets[name] ?? []);
429
+ }
430
+ function addDiscoveryMatch(matches, match) {
431
+ if (!matches.some((existing) => existing.field === match.field && existing.value === match.value && existing.reason === match.reason)) {
432
+ matches.push(match);
433
+ }
434
+ }
435
+ function tokenOverlapScore(tokens, value) {
436
+ if (tokens.length === 0 || !value) return 0;
437
+ const normalized = normalizeDiscoveryText(value);
438
+ const hits = tokens.filter((token) => normalized.includes(token)).length;
439
+ return hits / tokens.length;
440
+ }
441
+ function discoveryMatches(item, query) {
442
+ if (!query) return [];
443
+ const matches = [];
444
+ const tokens = discoveryTokens(query);
445
+ const canonicalQuery = canonicalDiscoveryName(query);
446
+ const idParts = item.id.split(/[:/]/);
447
+ const names = [
448
+ item.id,
449
+ item.title,
450
+ ...idParts,
451
+ ...facetValues(item, ["name", "canonical_name", "command", "skill", "agent", "rule"])
452
+ ].filter(Boolean);
453
+ const triggers = facetValues(item, ["trigger", "triggers", "trigger_phrase", "trigger_phrases"]);
454
+ const capabilities = [
455
+ ...facetValues(item, ["capability", "capabilities", "summary", "description"]),
456
+ ...item.concepts,
457
+ ...item.tags
458
+ ];
459
+ const sourceValues = [item.source?.path, item.source?.repo_relative_path, item.source?.locator].filter(Boolean);
460
+ for (const name of names) {
461
+ const canonicalName = canonicalDiscoveryName(name);
462
+ if (!canonicalName) continue;
463
+ if (canonicalName === canonicalQuery) {
464
+ addDiscoveryMatch(matches, { field: "id", value: name, score: 80, reason: "exact canonical name" });
465
+ } else if (canonicalName.includes(canonicalQuery) || canonicalQuery.includes(canonicalName)) {
466
+ addDiscoveryMatch(matches, { field: "id", value: name, score: 48, reason: "near canonical name" });
467
+ }
468
+ }
469
+ const titleOverlap = tokenOverlapScore(tokens, item.title);
470
+ if (titleOverlap > 0) addDiscoveryMatch(matches, { field: "title", value: item.title, score: 18 * titleOverlap, reason: "title token overlap" });
471
+ for (const trigger of triggers) {
472
+ const overlap = tokenOverlapScore(tokens, trigger);
473
+ if (overlap > 0) addDiscoveryMatch(matches, { field: "facet", value: trigger, score: 34 * overlap, reason: "trigger phrase" });
474
+ }
475
+ for (const capability of capabilities) {
476
+ const overlap = tokenOverlapScore(tokens, capability);
477
+ if (overlap > 0) addDiscoveryMatch(matches, { field: "concept", value: capability, score: 22 * overlap, reason: "capability overlap" });
478
+ }
479
+ const textOverlap = tokenOverlapScore(tokens, item.text);
480
+ if (textOverlap > 0) addDiscoveryMatch(matches, { field: "text", value: item.text, score: 8 * textOverlap, reason: "body token overlap" });
481
+ for (const source of sourceValues) {
482
+ const overlap = tokenOverlapScore(tokens, source);
483
+ if (overlap > 0) addDiscoveryMatch(matches, { field: "source", value: source, score: 2 * overlap, reason: "path overlap" });
484
+ }
485
+ return matches;
486
+ }
401
487
  function rankMatches(matches, weights) {
402
- return matches.reduce((total, match) => total + weights[match.field], 0);
488
+ return matches.reduce((total, match) => total + (match.score ?? weights[match.field]), 0);
403
489
  }
404
490
  function clipSnippet(value, q, maxLength) {
405
491
  const normalizedLength = Math.max(20, maxLength);
@@ -423,7 +509,12 @@ function createSnippet(item, matches, q, maxLength) {
423
509
  }
424
510
  function createRankedEntries(items, q, options, ordinalBase = 0) {
425
511
  const weights = { ...DEFAULT_QUERY_WEIGHTS, ...options.weights };
426
- return items.map((item, ordinal) => ({ item, ordinal: ordinalBase + ordinal, matches: queryMatches(item, q) })).filter(({ item, matches }) => {
512
+ const profile = options.searchProfile ?? "default";
513
+ return items.map((item, ordinal) => ({
514
+ item,
515
+ ordinal: ordinalBase + ordinal,
516
+ matches: profile === "aiwg-discovery" ? discoveryMatches(item, q) : queryMatches(item, q)
517
+ })).filter(({ item, matches }) => {
427
518
  if (q && matches.length === 0) return false;
428
519
  if (options.types && !options.types.includes(item.type)) return false;
429
520
  if (options.privacy && !options.privacy.includes(item.privacy.classification)) return false;
@@ -470,7 +561,106 @@ function createQueryResultFromRankedEntries(entries, query, options) {
470
561
  }
471
562
  function queryAiwgFortemiIndex(index, query = "", options = {}) {
472
563
  const q = query.trim().toLowerCase();
473
- return createQueryResultFromRankedEntries(createRankedEntries(index.items, q, options), q, options);
564
+ const entries = createRankedEntries(index.items, q, options);
565
+ if (entries.length === 0 && q && options.searchProfile === "aiwg-discovery") {
566
+ const relaxed = discoveryTokens(q).join(" ");
567
+ return createQueryResultFromRankedEntries(createRankedEntries(index.items, relaxed, options), relaxed, options);
568
+ }
569
+ return createQueryResultFromRankedEntries(entries, q, options);
570
+ }
571
+ function cosineSimilarity(left, right) {
572
+ if (left.length !== right.length || left.length === 0) return 0;
573
+ let dot = 0;
574
+ let leftMag = 0;
575
+ let rightMag = 0;
576
+ for (let i = 0; i < left.length; i += 1) {
577
+ const l = left[i];
578
+ const r = right[i];
579
+ dot += l * r;
580
+ leftMag += l * l;
581
+ rightMag += r * r;
582
+ }
583
+ if (leftMag === 0 || rightMag === 0) return 0;
584
+ return dot / (Math.sqrt(leftMag) * Math.sqrt(rightMag));
585
+ }
586
+ function validateAiwgStaticEmbeddingSet(value) {
587
+ const errors = [];
588
+ const data = value;
589
+ if (data?.schema_version !== "aiwg.fortemi.embedding.set.v1") errors.push("schema_version must be aiwg.fortemi.embedding.set.v1");
590
+ if (!hasString(data?.id)) errors.push("id is required");
591
+ if (!hasString(data?.model)) errors.push("model is required");
592
+ if (!hasPositiveInteger(data?.dimensions)) errors.push("dimensions must be a positive integer");
593
+ if (!hasString(data?.generated_at)) errors.push("generated_at is required");
594
+ if (!hasString(data?.granularity)) errors.push("granularity is required");
595
+ if (!Array.isArray(data?.embeddings)) errors.push("embeddings must be an array");
596
+ for (const [index, embedding] of (data.embeddings ?? []).entries()) {
597
+ if (!hasString(embedding.record_id)) errors.push("embeddings[" + index + "].record_id is required");
598
+ if (!hasString(embedding.input_hash)) errors.push("embeddings[" + index + "].input_hash is required");
599
+ if (!Array.isArray(embedding.embedding)) errors.push("embeddings[" + index + "].embedding must be an array");
600
+ else if (hasPositiveInteger(data?.dimensions) && embedding.embedding.length !== data.dimensions) {
601
+ errors.push("embeddings[" + index + "].embedding length must match dimensions");
602
+ } else if (!embedding.embedding.every((number) => typeof number === "number" && Number.isFinite(number))) {
603
+ errors.push("embeddings[" + index + "].embedding must contain finite numbers");
604
+ }
605
+ }
606
+ return { valid: errors.length === 0, errors };
607
+ }
608
+ function assertAiwgStaticEmbeddingSet(value) {
609
+ const result = validateAiwgStaticEmbeddingSet(value);
610
+ if (!result.valid) throw new Error("Invalid AIWG Fortemi embedding set:\n" + result.errors.join("\n"));
611
+ return value;
612
+ }
613
+ function queryAiwgSemanticIndex(index, embeddingSet, queryEmbedding, options = {}) {
614
+ assertAiwgStaticEmbeddingSet(embeddingSet);
615
+ if (queryEmbedding.length !== embeddingSet.dimensions) throw new Error("query embedding length must match embedding set dimensions");
616
+ const byId = new Map(index.items.map((item) => [item.id, item]));
617
+ const offset = options.offset ?? 0;
618
+ const limit = options.limit ?? 20;
619
+ return embeddingSet.embeddings.map((embedding) => {
620
+ const item = byId.get(embedding.record_id);
621
+ if (!item) return null;
622
+ return { item, embedding, score: cosineSimilarity(queryEmbedding, embedding.embedding) };
623
+ }).filter((result) => result !== null && result.score >= (options.minScore ?? -1)).sort((left, right) => right.score - left.score || left.item.id.localeCompare(right.item.id)).slice(offset, offset + limit);
624
+ }
625
+ function queryAiwgHybridIndex(index, embeddingSet, query, queryEmbedding, options = {}) {
626
+ const lexical = queryAiwgFortemiIndex(index, query, { ...options, rank: true });
627
+ const semantic = queryAiwgSemanticIndex(index, embeddingSet, queryEmbedding, { limit: index.items.length });
628
+ const lexicalWeight = options.lexicalWeight ?? 0.5;
629
+ const semanticWeight = options.semanticWeight ?? 0.5;
630
+ const lexicalScores = new Map(lexical.rankedItems?.map((entry) => [entry.item.id, entry.rank]) ?? []);
631
+ const maxLexical = Math.max(1, ...lexicalScores.values());
632
+ const embeddingById = new Map(semantic.flatMap((entry) => entry.embedding ? [[entry.item.id, entry.embedding]] : []));
633
+ const semanticScores = new Map(semantic.map((entry) => [entry.item.id, entry.score]));
634
+ const ids = /* @__PURE__ */ new Set([...lexicalScores.keys(), ...semanticScores.keys()]);
635
+ const offset = options.offset ?? 0;
636
+ const limit = options.limit ?? 20;
637
+ return [...ids].map((id) => {
638
+ const item = index.items.find((candidate) => candidate.id === id);
639
+ const embedding = embeddingById.get(id);
640
+ if (!item) return null;
641
+ return {
642
+ item,
643
+ ...embedding ? { embedding } : {},
644
+ score: (lexicalScores.get(id) ?? 0) / maxLexical * lexicalWeight + (semanticScores.get(id) ?? 0) * semanticWeight
645
+ };
646
+ }).filter((result) => result !== null && result.score >= (options.minScore ?? -1)).sort((left, right) => right.score - left.score || left.item.id.localeCompare(right.item.id)).slice(offset, offset + limit);
647
+ }
648
+ function findAiwgStaticDuplicatePairs(index, embeddingSet, threshold = 0.9) {
649
+ assertAiwgStaticEmbeddingSet(embeddingSet);
650
+ const byId = new Map(index.items.map((item) => [item.id, item]));
651
+ const pairs = [];
652
+ for (let leftIndex = 0; leftIndex < embeddingSet.embeddings.length; leftIndex += 1) {
653
+ for (let rightIndex = leftIndex + 1; rightIndex < embeddingSet.embeddings.length; rightIndex += 1) {
654
+ const leftEmbedding = embeddingSet.embeddings[leftIndex];
655
+ const rightEmbedding = embeddingSet.embeddings[rightIndex];
656
+ const left = byId.get(leftEmbedding.record_id);
657
+ const right = byId.get(rightEmbedding.record_id);
658
+ if (!left || !right) continue;
659
+ const score = cosineSimilarity(leftEmbedding.embedding, rightEmbedding.embedding);
660
+ if (score >= threshold) pairs.push({ left, right, score });
661
+ }
662
+ }
663
+ return pairs.sort((left, right) => right.score - left.score || left.left.id.localeCompare(right.left.id));
474
664
  }
475
665
  function chunkPartCacheKey(part) {
476
666
  return `${part.offset}:${part.href}`;
@@ -569,6 +759,104 @@ async function getChunkRecord(runtime, id) {
569
759
  }
570
760
  return record;
571
761
  }
762
+ function relationshipTypeFilter(options) {
763
+ return options?.relationshipType ?? options?.type;
764
+ }
765
+ function edgeFromRelationship(sourceId, relationship) {
766
+ return {
767
+ source_id: sourceId,
768
+ target_id: relationship.target_id,
769
+ type: relationship.type,
770
+ ...relationship.source_path ? { source_path: relationship.source_path } : {}
771
+ };
772
+ }
773
+ function relationshipMatches(edge, options = {}) {
774
+ const type = relationshipTypeFilter(options);
775
+ const direction = options.direction ?? "both";
776
+ if (type && edge.type !== type) return false;
777
+ if (options.sourceId && edge.source_id !== options.sourceId) return false;
778
+ if (options.targetId && edge.target_id !== options.targetId) return false;
779
+ if (direction === "out" && options.targetId && edge.target_id !== options.targetId) return false;
780
+ if (direction === "in" && options.sourceId && edge.source_id !== options.sourceId) return false;
781
+ return true;
782
+ }
783
+ function nodeSummary(item) {
784
+ return { id: item.id, type: item.type, title: item.title };
785
+ }
786
+ function addNode(nodes, item) {
787
+ if (item) nodes.set(item.id, nodeSummary(item));
788
+ }
789
+ function relationshipResultFromRecords(records, options = {}) {
790
+ const byId = new Map(records.map((record) => [record.id, record]));
791
+ const edges = [];
792
+ for (const record of records) {
793
+ for (const relationship of record.relationships ?? []) {
794
+ const edge = edgeFromRelationship(record.id, relationship);
795
+ if (!relationshipMatches(edge, options)) continue;
796
+ edges.push(edge);
797
+ }
798
+ }
799
+ const limitedEdges = (options.limit ? edges.slice(0, options.limit) : edges).sort((left, right) => left.source_id.localeCompare(right.source_id) || left.target_id.localeCompare(right.target_id) || left.type.localeCompare(right.type));
800
+ const nodes = /* @__PURE__ */ new Map();
801
+ for (const edge of limitedEdges) {
802
+ addNode(nodes, byId.get(edge.source_id));
803
+ addNode(nodes, byId.get(edge.target_id));
804
+ }
805
+ return {
806
+ nodes: [...nodes.values()].sort((left, right) => left.id.localeCompare(right.id)),
807
+ edges: limitedEdges,
808
+ complete: true
809
+ };
810
+ }
811
+ function neighborQueryOptions(id, options = {}) {
812
+ const direction = options.direction ?? "both";
813
+ return {
814
+ ...options,
815
+ ...direction === "out" ? { sourceId: id } : {},
816
+ ...direction === "in" ? { targetId: id } : {}
817
+ };
818
+ }
819
+ function filterNeighborResult(id, result, options = {}) {
820
+ const direction = options.direction ?? "both";
821
+ const edges = result.edges.filter((edge) => {
822
+ if (direction === "out") return edge.source_id === id;
823
+ if (direction === "in") return edge.target_id === id;
824
+ return edge.source_id === id || edge.target_id === id;
825
+ });
826
+ const ids = /* @__PURE__ */ new Set();
827
+ for (const edge of edges) {
828
+ ids.add(edge.source_id);
829
+ ids.add(edge.target_id);
830
+ }
831
+ return {
832
+ ...result,
833
+ edges,
834
+ nodes: result.nodes.filter((node) => ids.has(node.id))
835
+ };
836
+ }
837
+ async function recordsFromChunkedRuntime(runtime, onProgress) {
838
+ let scannedParts = 0;
839
+ let fetchedParts = 0;
840
+ const records = [];
841
+ for (const partRef of runtime.manifest.parts) {
842
+ const loaded = await loadChunkPart(runtime, partRef);
843
+ if (loaded.fetched) fetchedParts += 1;
844
+ scannedParts += 1;
845
+ onProgress?.({ phase: "part", done: scannedParts, total: runtime.manifest.parts.length, href: partRef.href });
846
+ for (const item of loaded.part.items) {
847
+ records.push(item.relationships ? item : await getChunkRecord(runtime, item.id));
848
+ }
849
+ }
850
+ return { records, scannedParts, fetchedParts };
851
+ }
852
+ async function relationshipResultFromChunkedRuntime(runtime, options = {}) {
853
+ const loaded = await recordsFromChunkedRuntime(runtime);
854
+ return {
855
+ ...relationshipResultFromRecords(loaded.records, options),
856
+ scannedParts: loaded.scannedParts,
857
+ fetchedParts: loaded.fetchedParts
858
+ };
859
+ }
572
860
  async function queryChunkedAiwgFortemiIndex(runtime, query = "", options = {}) {
573
861
  const q = query.trim().toLowerCase();
574
862
  let scannedParts = 0;
@@ -749,6 +1037,39 @@ function createAiwgIndexController(initialIndex) {
749
1037
  if (!found) throw new Error("Record not found: " + id);
750
1038
  return found;
751
1039
  },
1040
+ async neighbors(id, options) {
1041
+ try {
1042
+ const queryOptions = neighborQueryOptions(id, options);
1043
+ const result = chunked ? await relationshipResultFromChunkedRuntime(chunked, queryOptions) : relationshipResultFromRecords(requireIndex().items, queryOptions);
1044
+ return filterNeighborResult(id, result, options);
1045
+ } catch (err) {
1046
+ error = err instanceof Error ? err : new Error(String(err));
1047
+ notify();
1048
+ throw error;
1049
+ }
1050
+ },
1051
+ async relationshipQuery(options) {
1052
+ try {
1053
+ return chunked ? await relationshipResultFromChunkedRuntime(chunked, options) : relationshipResultFromRecords(requireIndex().items, options);
1054
+ } catch (err) {
1055
+ error = err instanceof Error ? err : new Error(String(err));
1056
+ notify();
1057
+ throw error;
1058
+ }
1059
+ },
1060
+ async relationshipSet(options) {
1061
+ const [left, right] = await Promise.all([
1062
+ this.neighbors(options.a, options),
1063
+ this.neighbors(options.b, options)
1064
+ ]);
1065
+ const leftIds = new Set(left.nodes.map((node) => node.id).filter((id) => id !== options.a));
1066
+ const rightIds = new Set(right.nodes.map((node) => node.id).filter((id) => id !== options.b));
1067
+ let ids;
1068
+ if (options.op === "intersection") ids = [...leftIds].filter((id) => rightIds.has(id));
1069
+ else if (options.op === "difference") ids = [...leftIds].filter((id) => !rightIds.has(id));
1070
+ else ids = [.../* @__PURE__ */ new Set([...leftIds, ...rightIds])];
1071
+ return { op: options.op, ids: ids.sort() };
1072
+ },
752
1073
  clearChunkCache() {
753
1074
  chunked?.partCache.clear();
754
1075
  chunked?.detailCache.clear();
@@ -759,6 +1080,15 @@ function createAiwgIndexController(initialIndex) {
759
1080
  toCommunityGraph(options) {
760
1081
  return aiwgFortemiIndexToCommunityGraph(requireIndex(), options);
761
1082
  },
1083
+ async toCommunityGraphChunked(options) {
1084
+ if (!chunked) return aiwgFortemiIndexToCommunityGraph(requireIndex(), options);
1085
+ const loaded = await recordsFromChunkedRuntime(chunked, options?.onProgress);
1086
+ return aiwgFortemiIndexToCommunityGraph({
1087
+ generated_at: chunked.manifest.generated_at,
1088
+ source: chunked.manifest.source,
1089
+ items: loaded.records
1090
+ }, options);
1091
+ },
762
1092
  setReviewDecision(input) {
763
1093
  const decision = {
764
1094
  ...input,
@@ -834,6 +1164,6 @@ function communityIdsFor(item, options) {
834
1164
  return [`type:${item.type}`];
835
1165
  }
836
1166
 
837
- export { AIWG_SCAN_REQUIRED_FIELDS, aiwgDetailHrefForId, aiwgFortemiIndexToCommunityGraph, assertAiwgFortemiChunkManifest, assertAiwgFortemiChunkPart, assertAiwgFortemiIndexExport, buildAiwgChunkedIndex, createAiwgFetchChunkLoader, createAiwgFetchDetailLoader, createAiwgIndexController, createAiwgReviewDecisionExport, encodeAiwgDetailId, getAiwgFortemiFacets, queryAiwgFortemiIndex, validateAiwgFortemiChunkManifest, validateAiwgFortemiChunkPart, validateAiwgFortemiIndexExport };
1167
+ export { AIWG_SCAN_REQUIRED_FIELDS, aiwgDetailHrefForId, aiwgFortemiIndexToCommunityGraph, assertAiwgFortemiChunkManifest, assertAiwgFortemiChunkPart, assertAiwgFortemiIndexExport, assertAiwgStaticEmbeddingSet, buildAiwgChunkedIndex, createAiwgFetchChunkLoader, createAiwgFetchDetailLoader, createAiwgIndexController, createAiwgReviewDecisionExport, encodeAiwgDetailId, findAiwgStaticDuplicatePairs, getAiwgFortemiFacets, queryAiwgFortemiIndex, queryAiwgHybridIndex, queryAiwgSemanticIndex, validateAiwgFortemiChunkManifest, validateAiwgFortemiChunkPart, validateAiwgFortemiIndexExport, validateAiwgStaticEmbeddingSet };
838
1168
  //# sourceMappingURL=aiwg-index.js.map
839
1169
  //# sourceMappingURL=aiwg-index.js.map