@bodhi-ventures/aiocs 0.5.2 → 0.6.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.
@@ -7,8 +7,10 @@ var AIOCS_ERROR_CODES = {
7
7
  noPagesFetched: "NO_PAGES_FETCHED",
8
8
  noProjectScope: "NO_PROJECT_SCOPE",
9
9
  chunkNotFound: "CHUNK_NOT_FOUND",
10
+ pageNotFound: "PAGE_NOT_FOUND",
10
11
  referenceFileNotFound: "REFERENCE_FILE_NOT_FOUND",
11
12
  invalidReferenceFile: "INVALID_REFERENCE_FILE",
13
+ sourceContextInvalid: "SOURCE_CONTEXT_INVALID",
12
14
  authEnvMissing: "AUTH_ENV_MISSING",
13
15
  canaryFailed: "CANARY_FAILED",
14
16
  backupConflict: "BACKUP_CONFLICT",
@@ -818,6 +820,35 @@ function initSchema(db) {
818
820
  PRIMARY KEY(project_path, source_id)
819
821
  );
820
822
 
823
+ CREATE TABLE IF NOT EXISTS source_context (
824
+ source_id TEXT PRIMARY KEY REFERENCES sources(id) ON DELETE CASCADE,
825
+ context_json TEXT NOT NULL,
826
+ created_at TEXT NOT NULL,
827
+ updated_at TEXT NOT NULL
828
+ );
829
+
830
+ CREATE TABLE IF NOT EXISTS routing_learnings (
831
+ id TEXT PRIMARY KEY,
832
+ route_key TEXT NOT NULL UNIQUE,
833
+ source_id TEXT NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
834
+ snapshot_id TEXT REFERENCES snapshots(id) ON DELETE SET NULL,
835
+ learning_type TEXT NOT NULL CHECK(learning_type IN ('discovery', 'negative')),
836
+ intent TEXT NOT NULL,
837
+ page_url TEXT,
838
+ file_path TEXT,
839
+ title TEXT,
840
+ note TEXT,
841
+ search_terms_json TEXT NOT NULL,
842
+ created_at TEXT NOT NULL,
843
+ updated_at TEXT NOT NULL
844
+ );
845
+
846
+ CREATE INDEX IF NOT EXISTS idx_routing_learnings_source_type
847
+ ON routing_learnings(source_id, learning_type, updated_at);
848
+
849
+ CREATE INDEX IF NOT EXISTS idx_routing_learnings_intent
850
+ ON routing_learnings(intent, updated_at);
851
+
821
852
  CREATE TABLE IF NOT EXISTS daemon_state (
822
853
  singleton_id INTEGER PRIMARY KEY CHECK(singleton_id = 1),
823
854
  last_started_at TEXT,
@@ -926,6 +957,15 @@ function normalizeQuery(query) {
926
957
  const words = query.replace(/[^\p{L}\p{N}]+/gu, " ").split(/\s+/).map((part) => part.trim()).filter(Boolean);
927
958
  return words.join(" ");
928
959
  }
960
+ function buildRouteKey(input) {
961
+ return sha256(stableStringify({
962
+ sourceId: input.sourceId,
963
+ learningType: input.learningType,
964
+ intent: normalizeQuery(input.intent).toLowerCase(),
965
+ pageUrl: input.pageUrl ?? null,
966
+ filePath: input.filePath ?? null
967
+ }));
968
+ }
929
969
  function normalizePatternFilters(patterns) {
930
970
  if (!patterns || patterns.length === 0) {
931
971
  return null;
@@ -1297,6 +1337,62 @@ function openCatalog(options) {
1297
1337
  }
1298
1338
  return JSON.parse(row.spec_json);
1299
1339
  },
1340
+ getSourceById(sourceId) {
1341
+ return this.listSources().find((source) => source.id === sourceId) ?? null;
1342
+ },
1343
+ upsertSourceContext(sourceId, context) {
1344
+ const source = this.getSourceById(sourceId);
1345
+ if (!source) {
1346
+ throw new AiocsError(
1347
+ AIOCS_ERROR_CODES.sourceNotFound,
1348
+ `Unknown source '${sourceId}'`
1349
+ );
1350
+ }
1351
+ const timestamp = nowIso();
1352
+ const existing = db.prepare(`
1353
+ SELECT created_at
1354
+ FROM source_context
1355
+ WHERE source_id = ?
1356
+ `).get(sourceId);
1357
+ db.prepare(`
1358
+ INSERT INTO source_context (source_id, context_json, created_at, updated_at)
1359
+ VALUES (?, ?, ?, ?)
1360
+ ON CONFLICT(source_id) DO UPDATE SET
1361
+ context_json = excluded.context_json,
1362
+ updated_at = excluded.updated_at
1363
+ `).run(
1364
+ sourceId,
1365
+ JSON.stringify(context),
1366
+ existing?.created_at ?? timestamp,
1367
+ timestamp
1368
+ );
1369
+ return {
1370
+ sourceId,
1371
+ context,
1372
+ createdAt: existing?.created_at ?? timestamp,
1373
+ updatedAt: timestamp
1374
+ };
1375
+ },
1376
+ getSourceContext(sourceId) {
1377
+ const source = this.getSourceById(sourceId);
1378
+ if (!source) {
1379
+ throw new AiocsError(
1380
+ AIOCS_ERROR_CODES.sourceNotFound,
1381
+ `Unknown source '${sourceId}'`
1382
+ );
1383
+ }
1384
+ const row = db.prepare(`
1385
+ SELECT context_json, created_at, updated_at
1386
+ FROM source_context
1387
+ WHERE source_id = ?
1388
+ `).get(sourceId);
1389
+ return {
1390
+ sourceId,
1391
+ context: row ? JSON.parse(row.context_json) : null,
1392
+ createdAt: row?.created_at ?? null,
1393
+ updatedAt: row?.updated_at ?? null
1394
+ };
1395
+ },
1300
1396
  listSources() {
1301
1397
  const rows = db.prepare(`
1302
1398
  SELECT
@@ -1335,6 +1431,280 @@ function openCatalog(options) {
1335
1431
  };
1336
1432
  });
1337
1433
  },
1434
+ listPages(input) {
1435
+ const source = this.getSourceById(input.sourceId);
1436
+ if (!source) {
1437
+ throw new AiocsError(
1438
+ AIOCS_ERROR_CODES.sourceNotFound,
1439
+ `Unknown source '${input.sourceId}'`
1440
+ );
1441
+ }
1442
+ const snapshotId = input.snapshotId ?? source.lastSuccessfulSnapshotId;
1443
+ if (!snapshotId) {
1444
+ throw new AiocsError(
1445
+ AIOCS_ERROR_CODES.snapshotNotFound,
1446
+ `No successful snapshot found for source '${input.sourceId}'`
1447
+ );
1448
+ }
1449
+ const snapshotRow = db.prepare(`
1450
+ SELECT id
1451
+ FROM snapshots
1452
+ WHERE id = ?
1453
+ AND source_id = ?
1454
+ `).get(snapshotId, input.sourceId);
1455
+ if (!snapshotRow) {
1456
+ throw new AiocsError(
1457
+ AIOCS_ERROR_CODES.snapshotNotFound,
1458
+ `Snapshot '${snapshotId}' not found for source '${input.sourceId}'`
1459
+ );
1460
+ }
1461
+ const normalizedQuery = input.query ? normalizeQuery(input.query).toLowerCase() : "";
1462
+ const pathPatterns = normalizePatternFilters(input.pathPatterns);
1463
+ const limit = assertPaginationValue(input.limit, "limit", 50);
1464
+ const offset = assertPaginationValue(input.offset, "offset", 0);
1465
+ const whereSql = [
1466
+ "snapshot_id = ?"
1467
+ ];
1468
+ const args = [snapshotId];
1469
+ if (normalizedQuery) {
1470
+ whereSql.push("(LOWER(title) LIKE ? OR LOWER(url) LIKE ? OR LOWER(COALESCE(file_path, '')) LIKE ?)");
1471
+ const queryLike = `%${normalizedQuery}%`;
1472
+ args.push(queryLike, queryLike, queryLike);
1473
+ }
1474
+ if (pathPatterns && pathPatterns.length > 0) {
1475
+ whereSql.push(`file_path IS NOT NULL AND (${pathPatterns.map(() => "file_path GLOB ?").join(" OR ")})`);
1476
+ args.push(...pathPatterns.map((pattern) => toSqliteGlob(pattern)));
1477
+ }
1478
+ const whereClause = whereSql.join(" AND ");
1479
+ const totalRow = db.prepare(`
1480
+ SELECT COUNT(*) AS total
1481
+ FROM pages
1482
+ WHERE ${whereClause}
1483
+ `).get(...args);
1484
+ const rows = db.prepare(`
1485
+ SELECT url, title, markdown, page_kind, file_path, language
1486
+ FROM pages
1487
+ WHERE ${whereClause}
1488
+ ORDER BY title, url
1489
+ LIMIT ?
1490
+ OFFSET ?
1491
+ `).all(...args, limit, offset);
1492
+ return {
1493
+ sourceId: input.sourceId,
1494
+ snapshotId,
1495
+ total: totalRow.total,
1496
+ limit,
1497
+ offset,
1498
+ hasMore: offset + rows.length < totalRow.total,
1499
+ pages: rows.map((row) => ({
1500
+ url: row.url,
1501
+ title: row.title,
1502
+ pageKind: row.page_kind,
1503
+ filePath: row.file_path,
1504
+ language: row.language,
1505
+ markdownLength: row.markdown.length
1506
+ }))
1507
+ };
1508
+ },
1509
+ getPage(input) {
1510
+ const source = this.getSourceById(input.sourceId);
1511
+ if (!source) {
1512
+ throw new AiocsError(
1513
+ AIOCS_ERROR_CODES.sourceNotFound,
1514
+ `Unknown source '${input.sourceId}'`
1515
+ );
1516
+ }
1517
+ if (!input.url && !input.filePath || input.url && input.filePath) {
1518
+ throw new AiocsError(
1519
+ AIOCS_ERROR_CODES.invalidArgument,
1520
+ "Provide exactly one of url or filePath"
1521
+ );
1522
+ }
1523
+ const snapshotId = input.snapshotId ?? source.lastSuccessfulSnapshotId;
1524
+ if (!snapshotId) {
1525
+ throw new AiocsError(
1526
+ AIOCS_ERROR_CODES.snapshotNotFound,
1527
+ `No successful snapshot found for source '${input.sourceId}'`
1528
+ );
1529
+ }
1530
+ const row = db.prepare(`
1531
+ SELECT p.snapshot_id, p.url, p.title, p.markdown, p.page_kind, p.file_path, p.language
1532
+ FROM pages p
1533
+ INNER JOIN snapshots s
1534
+ ON s.id = p.snapshot_id
1535
+ WHERE p.snapshot_id = ?
1536
+ AND s.source_id = ?
1537
+ AND ${input.url ? "p.url = ?" : "p.file_path = ?"}
1538
+ LIMIT 1
1539
+ `).get(snapshotId, input.sourceId, input.url ?? input.filePath);
1540
+ if (!row) {
1541
+ throw new AiocsError(
1542
+ AIOCS_ERROR_CODES.pageNotFound,
1543
+ input.url ? `Page '${input.url}' not found for source '${input.sourceId}'` : `Page path '${input.filePath}' not found for source '${input.sourceId}'`
1544
+ );
1545
+ }
1546
+ return {
1547
+ sourceId: input.sourceId,
1548
+ snapshotId,
1549
+ page: {
1550
+ url: row.url,
1551
+ title: row.title,
1552
+ markdown: row.markdown,
1553
+ pageKind: row.page_kind,
1554
+ filePath: row.file_path,
1555
+ language: row.language
1556
+ }
1557
+ };
1558
+ },
1559
+ upsertRoutingLearning(input) {
1560
+ const source = this.getSourceById(input.sourceId);
1561
+ if (!source) {
1562
+ throw new AiocsError(
1563
+ AIOCS_ERROR_CODES.sourceNotFound,
1564
+ `Unknown source '${input.sourceId}'`
1565
+ );
1566
+ }
1567
+ const timestamp = nowIso();
1568
+ const routeKey = buildRouteKey(input);
1569
+ const searchTerms = [...new Set((input.searchTerms ?? []).map((term) => term.trim()).filter(Boolean))];
1570
+ const validationSnapshotId = input.snapshotId ?? source.lastSuccessfulSnapshotId ?? null;
1571
+ const storedSnapshotId = input.snapshotId ?? null;
1572
+ const hasTargetPage = Boolean(input.pageUrl || input.filePath);
1573
+ if (input.snapshotId) {
1574
+ const snapshotRow = db.prepare(`
1575
+ SELECT id
1576
+ FROM snapshots
1577
+ WHERE id = ?
1578
+ AND source_id = ?
1579
+ `).get(input.snapshotId, input.sourceId);
1580
+ if (!snapshotRow) {
1581
+ throw new AiocsError(
1582
+ AIOCS_ERROR_CODES.snapshotNotFound,
1583
+ `Snapshot '${input.snapshotId}' not found for source '${input.sourceId}'`
1584
+ );
1585
+ }
1586
+ }
1587
+ if (hasTargetPage) {
1588
+ if (!validationSnapshotId) {
1589
+ throw new AiocsError(
1590
+ AIOCS_ERROR_CODES.snapshotNotFound,
1591
+ `No successful snapshot found for source '${input.sourceId}'`
1592
+ );
1593
+ }
1594
+ this.getPage({
1595
+ sourceId: input.sourceId,
1596
+ snapshotId: validationSnapshotId,
1597
+ ...input.pageUrl ? { url: input.pageUrl } : input.filePath ? { filePath: input.filePath } : {}
1598
+ });
1599
+ }
1600
+ const existing = db.prepare(`
1601
+ SELECT id, created_at
1602
+ FROM routing_learnings
1603
+ WHERE route_key = ?
1604
+ `).get(routeKey);
1605
+ const learningId = existing?.id ?? randomUUID();
1606
+ db.prepare(`
1607
+ INSERT INTO routing_learnings (
1608
+ id,
1609
+ route_key,
1610
+ source_id,
1611
+ snapshot_id,
1612
+ learning_type,
1613
+ intent,
1614
+ page_url,
1615
+ file_path,
1616
+ title,
1617
+ note,
1618
+ search_terms_json,
1619
+ created_at,
1620
+ updated_at
1621
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1622
+ ON CONFLICT(route_key) DO UPDATE SET
1623
+ snapshot_id = excluded.snapshot_id,
1624
+ title = excluded.title,
1625
+ note = excluded.note,
1626
+ search_terms_json = excluded.search_terms_json,
1627
+ updated_at = excluded.updated_at
1628
+ `).run(
1629
+ learningId,
1630
+ routeKey,
1631
+ input.sourceId,
1632
+ storedSnapshotId,
1633
+ input.learningType,
1634
+ input.intent.trim(),
1635
+ input.pageUrl ?? null,
1636
+ input.filePath ?? null,
1637
+ input.title ?? null,
1638
+ input.note ?? null,
1639
+ JSON.stringify(searchTerms),
1640
+ existing?.created_at ?? timestamp,
1641
+ timestamp
1642
+ );
1643
+ return {
1644
+ learningId,
1645
+ sourceId: input.sourceId,
1646
+ snapshotId: storedSnapshotId,
1647
+ learningType: input.learningType,
1648
+ intent: input.intent.trim(),
1649
+ pageUrl: input.pageUrl ?? null,
1650
+ filePath: input.filePath ?? null,
1651
+ title: input.title ?? null,
1652
+ note: input.note ?? null,
1653
+ searchTerms,
1654
+ createdAt: existing?.created_at ?? timestamp,
1655
+ updatedAt: timestamp
1656
+ };
1657
+ },
1658
+ listRoutingLearnings(input) {
1659
+ const whereSql = [];
1660
+ const args = [];
1661
+ if (input?.sourceId) {
1662
+ whereSql.push("source_id = ?");
1663
+ args.push(input.sourceId);
1664
+ }
1665
+ if (input?.learningType) {
1666
+ whereSql.push("learning_type = ?");
1667
+ args.push(input.learningType);
1668
+ }
1669
+ if (input?.intentQuery) {
1670
+ whereSql.push("LOWER(intent) LIKE ?");
1671
+ args.push(`%${normalizeQuery(input.intentQuery).toLowerCase()}%`);
1672
+ }
1673
+ const limit = assertPaginationValue(input?.limit, "limit", 50);
1674
+ const rows = db.prepare(`
1675
+ SELECT
1676
+ id,
1677
+ source_id,
1678
+ snapshot_id,
1679
+ learning_type,
1680
+ intent,
1681
+ page_url,
1682
+ file_path,
1683
+ title,
1684
+ note,
1685
+ search_terms_json,
1686
+ created_at,
1687
+ updated_at
1688
+ FROM routing_learnings
1689
+ ${whereSql.length > 0 ? `WHERE ${whereSql.join(" AND ")}` : ""}
1690
+ ORDER BY updated_at DESC, source_id, intent
1691
+ LIMIT ?
1692
+ `).all(...args, limit);
1693
+ return rows.map((row) => ({
1694
+ learningId: row.id,
1695
+ sourceId: row.source_id,
1696
+ snapshotId: row.snapshot_id,
1697
+ learningType: row.learning_type,
1698
+ intent: row.intent,
1699
+ pageUrl: row.page_url,
1700
+ filePath: row.file_path,
1701
+ title: row.title,
1702
+ note: row.note,
1703
+ searchTerms: JSON.parse(row.search_terms_json),
1704
+ createdAt: row.created_at,
1705
+ updatedAt: row.updated_at
1706
+ }));
1707
+ },
1338
1708
  listDueSourceIds(referenceTime = nowIso()) {
1339
1709
  const rows = db.prepare(`
1340
1710
  SELECT id
@@ -3997,7 +4367,7 @@ async function startDaemon(input) {
3997
4367
  // package.json
3998
4368
  var package_default = {
3999
4369
  name: "@bodhi-ventures/aiocs",
4000
- version: "0.5.2",
4370
+ version: "0.6.0",
4001
4371
  license: "MIT",
4002
4372
  type: "module",
4003
4373
  description: "Local-only documentation store, fetcher, and search CLI for AI agents.",
@@ -5052,7 +5422,121 @@ async function searchHybridCatalog(input) {
5052
5422
  };
5053
5423
  }
5054
5424
 
5425
+ // src/retrieval.ts
5426
+ function tokenize(value) {
5427
+ return value.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").split(/\s+/).map((part) => part.trim()).filter(Boolean);
5428
+ }
5429
+ function overlapScore(queryTokens, candidate) {
5430
+ const candidateTokens = new Set(tokenize(candidate));
5431
+ if (candidateTokens.size === 0) {
5432
+ return 0;
5433
+ }
5434
+ let matches = 0;
5435
+ for (const token of queryTokens) {
5436
+ if (candidateTokens.has(token)) {
5437
+ matches += 1;
5438
+ }
5439
+ }
5440
+ return matches;
5441
+ }
5442
+ function scoreLearning(query, learning) {
5443
+ const queryTokens = tokenize(query);
5444
+ const candidates = [learning.intent, ...learning.searchTerms];
5445
+ const bestOverlap = Math.max(0, ...candidates.map((candidate) => overlapScore(queryTokens, candidate)));
5446
+ const exactIntentBoost = learning.intent.trim().toLowerCase() === query.trim().toLowerCase() ? 10 : 0;
5447
+ const recencyBoost = learning.learningType === "discovery" ? 1 : 0;
5448
+ return bestOverlap + exactIntentBoost + recencyBoost;
5449
+ }
5450
+ function scoreSourceContext(query, context) {
5451
+ if (!context) {
5452
+ return 0;
5453
+ }
5454
+ const queryTokens = tokenize(query);
5455
+ const candidates = [
5456
+ context.purpose ?? "",
5457
+ context.summary ?? "",
5458
+ ...context.topicHints,
5459
+ ...context.gotchas,
5460
+ ...context.authNotes,
5461
+ ...context.commonLocations.flatMap((location) => [location.label, location.note ?? "", location.url ?? "", location.filePath ?? ""])
5462
+ ];
5463
+ return Math.max(0, ...candidates.map((candidate) => overlapScore(queryTokens, candidate)));
5464
+ }
5465
+
5466
+ // src/source-context.ts
5467
+ import { readFile as readFile4 } from "fs/promises";
5468
+ import { extname as extname3 } from "path";
5469
+ import YAML2 from "yaml";
5470
+ import { z as z2 } from "zod";
5471
+ var commonLocationSchema = z2.object({
5472
+ label: z2.string().min(1),
5473
+ url: z2.string().url().optional(),
5474
+ filePath: z2.string().min(1).optional(),
5475
+ note: z2.string().min(1).optional()
5476
+ }).superRefine((value, context) => {
5477
+ if (!value.url && !value.filePath) {
5478
+ context.addIssue({
5479
+ code: z2.ZodIssueCode.custom,
5480
+ message: "common location entries must include url or filePath",
5481
+ path: ["url"]
5482
+ });
5483
+ }
5484
+ });
5485
+ var sourceContextSchema = z2.object({
5486
+ purpose: z2.string().min(1).optional(),
5487
+ summary: z2.string().min(1).optional(),
5488
+ topicHints: z2.array(z2.string().min(1)).default([]),
5489
+ commonLocations: z2.array(commonLocationSchema).default([]),
5490
+ gotchas: z2.array(z2.string().min(1)).default([]),
5491
+ authNotes: z2.array(z2.string().min(1)).default([])
5492
+ });
5493
+ function parseSourceContext(raw, extension) {
5494
+ if (extension === ".json") {
5495
+ return JSON.parse(raw);
5496
+ }
5497
+ return YAML2.parse(raw);
5498
+ }
5499
+ async function loadSourceContextFile(path) {
5500
+ try {
5501
+ const raw = await readFile4(path, "utf8");
5502
+ return sourceContextSchema.parse(parseSourceContext(raw, extname3(path).toLowerCase()));
5503
+ } catch (error) {
5504
+ if (error instanceof z2.ZodError) {
5505
+ throw new AiocsError(
5506
+ AIOCS_ERROR_CODES.sourceContextInvalid,
5507
+ `Invalid source context file '${path}'`,
5508
+ {
5509
+ issues: error.issues.map((issue) => ({
5510
+ path: issue.path.join("."),
5511
+ message: issue.message
5512
+ }))
5513
+ }
5514
+ );
5515
+ }
5516
+ if (error instanceof Error) {
5517
+ throw new AiocsError(
5518
+ AIOCS_ERROR_CODES.sourceContextInvalid,
5519
+ `Failed to load source context file '${path}': ${error.message}`
5520
+ );
5521
+ }
5522
+ throw error;
5523
+ }
5524
+ }
5525
+
5055
5526
  // src/services.ts
5527
+ function dedupePagesByIdentity(rows) {
5528
+ const seen = /* @__PURE__ */ new Set();
5529
+ const deduped = [];
5530
+ for (const row of rows) {
5531
+ const key = `${row.sourceId}::${row.snapshotId}::${row.filePath ?? row.pageUrl}`;
5532
+ if (seen.has(key)) {
5533
+ continue;
5534
+ }
5535
+ seen.add(key);
5536
+ deduped.push(row);
5537
+ }
5538
+ return deduped;
5539
+ }
5056
5540
  function createCatalog() {
5057
5541
  const dataDir = getAiocsDataDir();
5058
5542
  getAiocsConfigDir();
@@ -5079,6 +5563,70 @@ async function listSources() {
5079
5563
  const sources = await withCatalog(({ catalog }) => catalog.listSources());
5080
5564
  return { sources };
5081
5565
  }
5566
+ async function describeSource(sourceId) {
5567
+ return withCatalog(({ catalog }) => {
5568
+ const source = catalog.getSourceById(sourceId);
5569
+ if (!source) {
5570
+ throw new AiocsError(
5571
+ AIOCS_ERROR_CODES.sourceNotFound,
5572
+ `Unknown source '${sourceId}'`
5573
+ );
5574
+ }
5575
+ const latestSnapshot = catalog.listSnapshots(sourceId)[0] ?? null;
5576
+ return {
5577
+ source,
5578
+ context: catalog.getSourceContext(sourceId),
5579
+ latestSnapshot,
5580
+ recentLearnings: catalog.listRoutingLearnings({
5581
+ sourceId,
5582
+ limit: 10
5583
+ })
5584
+ };
5585
+ });
5586
+ }
5587
+ async function upsertSourceContextFromFile(sourceId, contextFile) {
5588
+ const resolvedContextFile = resolve8(contextFile);
5589
+ const context = await loadSourceContextFile(resolvedContextFile);
5590
+ const result = await withCatalog(({ catalog }) => catalog.upsertSourceContext(sourceId, context));
5591
+ return {
5592
+ ...result,
5593
+ contextFile: resolvedContextFile
5594
+ };
5595
+ }
5596
+ async function getSourceContextForSource(sourceId) {
5597
+ return withCatalog(({ catalog }) => catalog.getSourceContext(sourceId));
5598
+ }
5599
+ async function listSourcePages(sourceId, options) {
5600
+ return withCatalog(({ catalog }) => catalog.listPages({
5601
+ sourceId,
5602
+ ...options.snapshot ? { snapshotId: options.snapshot } : {},
5603
+ ...options.query ? { query: options.query } : {},
5604
+ ...options.path && options.path.length > 0 ? { pathPatterns: options.path } : {},
5605
+ ...typeof options.limit === "number" ? { limit: options.limit } : {},
5606
+ ...typeof options.offset === "number" ? { offset: options.offset } : {}
5607
+ }));
5608
+ }
5609
+ async function showPage(input) {
5610
+ return withCatalog(({ catalog }) => catalog.getPage(input));
5611
+ }
5612
+ async function saveRoutingLearning(input) {
5613
+ const learning = await withCatalog(({ catalog }) => catalog.upsertRoutingLearning({
5614
+ sourceId: input.sourceId,
5615
+ ...input.snapshotId ? { snapshotId: input.snapshotId } : {},
5616
+ learningType: input.learningType,
5617
+ intent: input.intent,
5618
+ ...input.pageUrl ? { pageUrl: input.pageUrl } : {},
5619
+ ...input.filePath ? { filePath: input.filePath } : {},
5620
+ ...input.title ? { title: input.title } : {},
5621
+ ...input.note ? { note: input.note } : {},
5622
+ ...input.searchTerms ? { searchTerms: input.searchTerms } : {}
5623
+ }));
5624
+ return { learning };
5625
+ }
5626
+ async function listRoutingLearningsForQuery(input) {
5627
+ const learnings = await withCatalog(({ catalog }) => catalog.listRoutingLearnings(input));
5628
+ return { learnings };
5629
+ }
5082
5630
  async function fetchSources(sourceIdOrAll) {
5083
5631
  const results = await withCatalog(async ({ catalog, dataDir }) => {
5084
5632
  const sourceIds = sourceIdOrAll === "all" ? catalog.listSources().map((item) => item.id) : [sourceIdOrAll];
@@ -5222,6 +5770,125 @@ async function searchCatalog(query, options) {
5222
5770
  results: results.results
5223
5771
  };
5224
5772
  }
5773
+ async function retrieveContext(query, options) {
5774
+ const cwd = options.project ? resolve8(options.project) : process.cwd();
5775
+ const explicitSources = options.source.length > 0;
5776
+ const pageLimit = typeof options.pageLimit === "number" ? options.pageLimit : 3;
5777
+ return withCatalog(async ({ catalog }) => {
5778
+ const hybridConfig = getHybridRuntimeConfig();
5779
+ const scope = resolveProjectScope(cwd, catalog.listProjectLinks());
5780
+ if (!explicitSources && !options.all && !scope) {
5781
+ throw new AiocsError(
5782
+ AIOCS_ERROR_CODES.noProjectScope,
5783
+ "No linked project scope found. Use --source or --all."
5784
+ );
5785
+ }
5786
+ const sourceScope = explicitSources ? options.source : options.all ? catalog.listSources().map((source) => source.id) : scope?.sourceIds ?? [];
5787
+ const learnings = catalog.listRoutingLearnings({
5788
+ limit: 100
5789
+ }).filter((learning) => sourceScope.length === 0 || sourceScope.includes(learning.sourceId));
5790
+ const scoredLearnings = learnings.map((learning) => ({
5791
+ ...learning,
5792
+ score: scoreLearning(query, learning)
5793
+ })).filter((learning) => learning.score > 0).sort((left, right) => right.score - left.score || right.updatedAt.localeCompare(left.updatedAt));
5794
+ const matchedLearnings = scoredLearnings.filter((learning) => learning.learningType === "discovery");
5795
+ const avoidedLearnings = scoredLearnings.filter((learning) => learning.learningType === "negative");
5796
+ const avoidedPageKeys = new Set(
5797
+ avoidedLearnings.map((learning) => `${learning.sourceId}::${learning.filePath ?? learning.pageUrl ?? ""}`)
5798
+ );
5799
+ const sourceHints = sourceScope.map((sourceId) => {
5800
+ const contextRecord = catalog.getSourceContext(sourceId);
5801
+ const score = scoreSourceContext(query, contextRecord.context);
5802
+ if (!contextRecord.context || score <= 0) {
5803
+ return null;
5804
+ }
5805
+ const matchedCommonLocations = contextRecord.context.commonLocations.filter(
5806
+ (location) => scoreSourceContext(query, {
5807
+ purpose: "",
5808
+ summary: "",
5809
+ topicHints: [],
5810
+ commonLocations: [location],
5811
+ gotchas: [],
5812
+ authNotes: []
5813
+ }) > 0
5814
+ );
5815
+ return {
5816
+ sourceId,
5817
+ score,
5818
+ context: contextRecord.context,
5819
+ matchedCommonLocations
5820
+ };
5821
+ }).filter((entry) => Boolean(entry)).sort((left, right) => right.score - left.score || left.sourceId.localeCompare(right.sourceId));
5822
+ const search = await searchHybridCatalog({
5823
+ catalog,
5824
+ config: hybridConfig,
5825
+ query,
5826
+ mode: options.mode ?? hybridConfig.defaultSearchMode,
5827
+ searchInput: {
5828
+ cwd,
5829
+ ...explicitSources ? { sourceIds: options.source } : {},
5830
+ ...options.snapshot ? { snapshotId: options.snapshot } : {},
5831
+ ...options.all ? { all: true } : {},
5832
+ ...options.path && options.path.length > 0 ? { pathPatterns: options.path } : {},
5833
+ ...options.language && options.language.length > 0 ? { languages: options.language } : {},
5834
+ ...typeof options.limit === "number" ? { limit: options.limit } : {},
5835
+ ...typeof options.offset === "number" ? { offset: options.offset } : {}
5836
+ }
5837
+ });
5838
+ const learnedPages = matchedLearnings.filter((learning) => learning.pageUrl || learning.filePath).map((learning) => ({
5839
+ sourceId: learning.sourceId,
5840
+ snapshotId: learning.snapshotId ?? catalog.getSourceById(learning.sourceId)?.lastSuccessfulSnapshotId ?? "",
5841
+ pageUrl: learning.pageUrl ?? "",
5842
+ filePath: learning.filePath ?? null
5843
+ })).filter((entry) => entry.snapshotId);
5844
+ const searchedPages = search.results.filter((result) => !avoidedPageKeys.has(`${result.sourceId}::${result.filePath ?? result.pageUrl}`)).map((result) => ({
5845
+ sourceId: result.sourceId,
5846
+ snapshotId: result.snapshotId,
5847
+ pageUrl: result.pageUrl,
5848
+ filePath: result.filePath
5849
+ }));
5850
+ const selectedPages = dedupePagesByIdentity([
5851
+ ...learnedPages,
5852
+ ...searchedPages
5853
+ ]).slice(0, Math.max(1, pageLimit));
5854
+ const pages = selectedPages.flatMap((entry) => {
5855
+ try {
5856
+ const page = catalog.getPage({
5857
+ sourceId: entry.sourceId,
5858
+ snapshotId: entry.snapshotId,
5859
+ ...entry.filePath ? { filePath: entry.filePath } : { url: entry.pageUrl }
5860
+ });
5861
+ return [{
5862
+ sourceId: page.sourceId,
5863
+ snapshotId: page.snapshotId,
5864
+ ...page.page
5865
+ }];
5866
+ } catch (error) {
5867
+ if (error instanceof AiocsError && (error.code === AIOCS_ERROR_CODES.pageNotFound || error.code === AIOCS_ERROR_CODES.snapshotNotFound)) {
5868
+ return [];
5869
+ }
5870
+ throw error;
5871
+ }
5872
+ });
5873
+ return {
5874
+ query,
5875
+ modeRequested: search.modeRequested,
5876
+ modeUsed: search.modeUsed,
5877
+ sourceScope,
5878
+ sourceHints,
5879
+ matchedLearnings,
5880
+ avoidedLearnings,
5881
+ search: {
5882
+ total: search.total,
5883
+ limit: search.limit,
5884
+ offset: search.offset,
5885
+ hasMore: search.hasMore,
5886
+ results: search.results
5887
+ },
5888
+ pages
5889
+ };
5890
+ });
5891
+ }
5225
5892
  async function showChunk(chunkId) {
5226
5893
  const chunk = await withCatalog(({ catalog }) => catalog.getChunkById(chunkId));
5227
5894
  if (!chunk) {
@@ -5361,6 +6028,13 @@ export {
5361
6028
  packageDescription,
5362
6029
  upsertSourceFromSpecFile,
5363
6030
  listSources,
6031
+ describeSource,
6032
+ upsertSourceContextFromFile,
6033
+ getSourceContextForSource,
6034
+ listSourcePages,
6035
+ showPage,
6036
+ saveRoutingLearning,
6037
+ listRoutingLearningsForQuery,
5364
6038
  fetchSources,
5365
6039
  refreshDueSources,
5366
6040
  runSourceCanaries,
@@ -5369,6 +6043,7 @@ export {
5369
6043
  linkProjectSources,
5370
6044
  unlinkProjectSources,
5371
6045
  searchCatalog,
6046
+ retrieveContext,
5372
6047
  showChunk,
5373
6048
  verifyCoverage,
5374
6049
  initManagedSources,