sdtk-wiki-kit 0.2.1 → 0.2.2

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
@@ -369,6 +369,14 @@ Preconditions:
369
369
  - local `wiki.ask` entitlement must be valid
370
370
  - local premium/runtime Ask pack must be available
371
371
 
372
+ When `--source` is given, Ask grounds on the chosen doc **plus its directly-related
373
+ siblings** — other docs that share the same BK issue, knowledge id, skill, or family.
374
+ The related docs are bounded (capped) and labelled as related, so a question about
375
+ one spec also sees its plan, test cases, and knowledge objects. No-filter (all docs)
376
+ grounding is unchanged.
377
+
378
+ The viewer's **Ask** panel has a scope picker ("Ask within"): All Docs (default), By folder (path-prefix filter with live count list), This doc + related (mapped to `--source` / `current-focus` with hyperedge expansion), and Selected docs (explicit attachment). Each answer shows a collapsible **Sources used (N)** panel with `primary` / `related` labels.
379
+
372
380
  Query history is off by default. `--save-query` writes one redacted JSON record
373
381
  under `.sdtk/wiki/queries` only after a successful answer.
374
382
 
@@ -359,6 +359,7 @@ body.nav-open .page-nav-toggle{display:none}
359
359
  #atlas-ask-model-select{flex:0 0 136px;width:136px}
360
360
  #atlas-ask-mode-select{flex:1 1 140px;width:140px}
361
361
  #atlas-ask-info-toggle{flex:0 0 36px}
362
+ #atlas-ask-path-filter{padding:0 12px;background-image:none;flex:1 1 120px}
362
363
  .atlas-ask-send{display:inline-flex;align-items:center;justify-content:center;flex:0 0 42px;width:42px;height:42px;border-radius:999px;border:0;background:linear-gradient(135deg,#4f78ff 0%,#3156c9 100%);color:#ffffff;cursor:pointer;font-size:20px;font-weight:900;box-shadow:0 14px 24px rgba(49,86,201,0.26)}
363
364
  .atlas-ask-send:hover{filter:brightness(1.03)}
364
365
  .atlas-ask-send:disabled{opacity:.5;cursor:not-allowed}
@@ -407,6 +408,47 @@ body.nav-open .page-nav-toggle{display:none}
407
408
  .atlas-ask-history-item strong{font-size:12px;color:#24344d}
408
409
  .atlas-ask-history-meta{font-size:11px;color:#6d7d95;line-height:1.4}
409
410
  .atlas-ask-history-preview{font-size:12px;color:#52606f;line-height:1.45}
411
+ .ask-scope-wrap{position:relative;flex:1 1 150px;min-width:0}
412
+ .ask-scope-btn{display:flex;align-items:center;gap:8px;width:100%;height:36px;padding:0 12px;border-radius:999px;border:1px solid #d9cfbf;background:#fff;color:#24344d;font-size:12px;font-weight:700;cursor:pointer;box-shadow:0 8px 18px rgba(15,23,42,0.04);text-align:left}
413
+ .ask-scope-btn:hover{border-color:#aebfe7;background:#f6f8ff}
414
+ .ask-scope-btn .scope-ico{flex:0 0 auto}
415
+ .ask-scope-btn .scope-label{flex:1 1 auto;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
416
+ .ask-scope-btn .scope-caret{flex:0 0 auto;color:#7d8fa8;font-size:9px}
417
+ .ask-scope-menu{display:none;position:absolute;left:0;bottom:calc(100% + 8px);z-index:5;flex-direction:column;gap:2px;padding:8px;min-width:248px;max-width:320px;background:rgba(255,255,255,0.99);border:1px solid #d9cfbf;border-radius:18px;box-shadow:0 18px 30px rgba(94,83,63,0.18);max-height:340px;overflow:auto}
418
+ .ask-scope-wrap.open .ask-scope-menu{display:flex}
419
+ .ask-scope-opt{display:flex;align-items:center;gap:10px;padding:9px 10px;border-radius:12px;cursor:pointer;color:#24344d}
420
+ .ask-scope-opt:hover{background:#f7f9ff}
421
+ .ask-scope-opt.active{background:#eef3ff;border:1px solid #cad7ff;padding:8px 9px}
422
+ .ask-scope-opt.disabled{opacity:.45;cursor:not-allowed;pointer-events:none}
423
+ .ask-scope-opt .opt-ico{flex:0 0 18px;text-align:center}
424
+ .ask-scope-opt .opt-copy{display:flex;align-items:center;min-width:0;flex:1}
425
+ .ask-scope-opt .opt-copy strong{font-size:12.5px;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
426
+ .ask-scope-opt .opt-check{flex:0 0 auto;color:#3156c9;font-weight:900;visibility:hidden}
427
+ .ask-scope-opt.active .opt-check{visibility:visible}
428
+ .ask-scope-sep{height:1px;background:#ece4d8;margin:4px 2px}
429
+ .ask-folder-block{display:none;flex-direction:column;gap:6px;padding:4px 4px 6px;margin:2px 0 0;border-top:1px dashed #e6ded1}
430
+ .ask-folder-block.open{display:flex}
431
+ .ask-folder-filter{height:32px;border-radius:999px;border:1px solid #d9cfbf;background:#fff;padding:0 12px;font:inherit;font-size:12px;color:#24344d}
432
+ .ask-folder-filter:focus{outline:none;border-color:#5f89ff;box-shadow:0 0 0 3px rgba(95,137,255,0.12)}
433
+ .ask-folder-list{display:flex;flex-direction:column;gap:1px;max-height:170px;overflow:auto}
434
+ .ask-folder-item{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:7px 10px;border-radius:10px;cursor:pointer;font-size:12px}
435
+ .ask-folder-item:hover{background:#f7f9ff}
436
+ .ask-folder-item.active{background:#eef3ff}
437
+ .ask-folder-item .f-path{font-family:'SF Mono',Consolas,monospace;color:#354153;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
438
+ .ask-folder-item .f-count{flex:0 0 auto;font-size:11px;font-weight:800;color:#6d7d95;background:#f3efe7;border:1px solid #e6ded1;border-radius:999px;padding:1px 8px}
439
+ .ask-sources{margin-top:2px;border:1px solid #e6ded1;border-radius:14px;background:rgba(255,255,255,0.9);overflow:hidden}
440
+ .ask-sources-head{display:flex;align-items:center;gap:8px;width:100%;padding:10px 12px;background:none;border:0;cursor:pointer;color:#52606f;font-size:12px;font-weight:700;text-align:left}
441
+ .ask-sources-head:hover{background:#f7f9ff}
442
+ .ask-sources-caret{transition:transform .15s ease;color:#7d8fa8;font-size:10px}
443
+ .ask-sources.open .ask-sources-caret{transform:rotate(90deg)}
444
+ .ask-sources-badge{display:inline-flex;align-items:center;justify-content:center;min-width:20px;height:18px;padding:0 6px;border-radius:999px;background:#3156c9;color:#fff;font-size:11px;font-weight:800}
445
+ .ask-sources-body{display:none;flex-direction:column;gap:6px;padding:4px 12px 12px}
446
+ .ask-sources.open .ask-sources-body{display:flex}
447
+ .ask-source-row{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:7px 10px;border:1px solid #ece4d8;border-radius:10px;background:#fff}
448
+ .ask-source-row .s-path{font-family:'SF Mono',Consolas,monospace;font-size:11.5px;color:#354153;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
449
+ .s-chip{flex:0 0 auto;font-size:10px;font-weight:800;letter-spacing:.04em;text-transform:uppercase;border-radius:999px;padding:2px 8px}
450
+ .s-chip.primary{color:#2f8f5b;background:#e7f6ee;border:1px solid #bfe6cf}
451
+ .s-chip.related{color:#3156c9;background:#eef3ff;border:1px solid #cad7ff}
410
452
  @media (max-width: 1180px){
411
453
  .atlas-ask-dock{position:relative;left:auto;top:auto;bottom:auto;width:100%;max-width:none;margin-top:14px}
412
454
  .atlas-ask-dock:not(.open){width:100%}
@@ -614,7 +656,7 @@ body.nav-open .page-nav-toggle{display:none}
614
656
  <button class="graph-focus-toggle atlas-ask-collapse" id="atlas-ask-toggle" title="Hide Ask SDTK-WIKI" onclick="toggleWikiAsk(false)">&#9662;</button>
615
657
  <div class="atlas-ask-attachments" id="atlas-ask-attachments"></div>
616
658
  <div class="atlas-ask-input-wrap">
617
- <textarea class="atlas-ask-input" id="atlas-ask-input" placeholder="Ask about the visible graph group, current focus, or attached docs..."></textarea>
659
+ <textarea class="atlas-ask-input" id="atlas-ask-input" placeholder="Ask something about your docs..."></textarea>
618
660
  <div class="atlas-ask-mention-results" id="atlas-ask-mention-results"></div>
619
661
  </div>
620
662
  <div class="atlas-ask-source-summary" id="atlas-ask-source-summary"></div>
@@ -622,11 +664,40 @@ body.nav-open .page-nav-toggle{display:none}
622
664
  <div class="atlas-ask-controls">
623
665
  <select class="atlas-ask-mode-select" id="atlas-ask-runtime-select" onchange="setWikiAskRuntimeAgent(this.value)" aria-label="Select runtime agent"></select>
624
666
  <select class="atlas-ask-mode-select" id="atlas-ask-model-select" onchange="setWikiAskModel(this.value)" aria-label="Select model"></select>
625
- <select class="atlas-ask-mode-select" id="atlas-ask-mode-select" onchange="setWikiAskMode(this.value)" aria-label="Select grounding mode">
626
- <option value="visible-group">Visible Group</option>
627
- <option value="current-focus">Current Focus</option>
628
- <option value="selected-docs">Selected Docs</option>
629
- </select>
667
+ <div class="ask-scope-wrap" id="ask-scope-wrap">
668
+ <button class="ask-scope-btn" id="ask-scope-btn" onclick="toggleWikiAskScopeMenu(event)" aria-haspopup="listbox" aria-expanded="false" title="Select Ask scope">
669
+ <span class="scope-ico" id="ask-scope-ico">&#128218;</span>
670
+ <span class="scope-label" id="ask-scope-label">All Docs</span>
671
+ <span class="scope-caret">&#9650;</span>
672
+ </button>
673
+ <div class="ask-scope-menu" id="ask-scope-menu" role="listbox">
674
+ <div class="ask-scope-opt active" data-scope="all" role="option" tabindex="0" onclick="pickWikiAskScope('all')" title="Hỏi trên toàn bộ tài liệu đã index (mặc định).">
675
+ <span class="opt-ico">&#128218;</span>
676
+ <span class="opt-copy"><strong>All Docs</strong></span>
677
+ <span class="opt-check">&#10003;</span>
678
+ </div>
679
+ <div class="ask-scope-opt" id="ask-scope-opt-folder" data-scope="folder" role="option" tabindex="0" onclick="toggleWikiAskFolderBlock(event)" title="Chọn một thư mục có thật để giới hạn phạm vi hỏi.">
680
+ <span class="opt-ico">&#128193;</span>
681
+ <span class="opt-copy"><strong>By folder</strong></span>
682
+ <span class="opt-check">&#10003;</span>
683
+ </div>
684
+ <div class="ask-folder-block" id="ask-folder-block">
685
+ <input class="ask-folder-filter" id="ask-folder-filter" type="text" placeholder="Filter folders&#x2026;" oninput="filterWikiAskFolders(this.value)" aria-label="Filter folders by name">
686
+ <div class="ask-folder-list" id="ask-folder-list"></div>
687
+ </div>
688
+ <div class="ask-scope-sep"></div>
689
+ <div class="ask-scope-opt" id="ask-scope-opt-focus" data-scope="focus" role="option" tabindex="0" onclick="pickWikiAskScope('focus')" title="T&#xE0;i li&#x1EC7;u &#x111;ang focus + c&#xE1;c t&#xE0;i li&#x1EC7;u li&#xEA;n quan c&#xF9;ng BK / knowledge / skill.">
690
+ <span class="opt-ico">&#127919;</span>
691
+ <span class="opt-copy"><strong>This doc + related</strong></span>
692
+ <span class="opt-check">&#10003;</span>
693
+ </div>
694
+ <div class="ask-scope-opt" id="ask-scope-opt-selected" data-scope="selected" role="option" tabindex="0" onclick="pickWikiAskScope('selected')" title="Ch&#x1EC9; h&#x1ECF;i tr&#xEA;n c&#xE1;c t&#xE0;i li&#x1EC7;u b&#x1EA1;n &#x111;&#xE3; &#x111;&#xED;nh k&#xE8;m.">
695
+ <span class="opt-ico">&#10003;</span>
696
+ <span class="opt-copy"><strong id="ask-scope-selected-label">Selected docs</strong></span>
697
+ <span class="opt-check">&#10003;</span>
698
+ </div>
699
+ </div>
700
+ </div>
630
701
  <button class="atlas-ask-icon-btn" id="atlas-ask-info-toggle" title="Show grounding info" onclick="toggleWikiAskAdvanced()">i</button>
631
702
  </div>
632
703
  <button class="atlas-ask-send" id="atlas-ask-submit" onclick="submitWikiAsk()" aria-label="Submit SDTK-WIKI Ask" title="Submit SDTK-WIKI Ask">&#8593;</button>
@@ -879,7 +950,8 @@ const graphState = {
879
950
  };
880
951
  const wikiAskState = {
881
952
  open: false,
882
- mode: 'visible-group',
953
+ mode: 'all-docs',
954
+ docPathFilter: '',
883
955
  selectedDocIds: [],
884
956
  status: 'idle',
885
957
  answer: null,
@@ -1433,8 +1505,165 @@ function openGraphToolbarPeek(event) {
1433
1505
  toggleGraphToolbarMinimized(false);
1434
1506
  }
1435
1507
  function getWikiAskModeLabel(mode) {
1436
- return ({ 'current-focus': 'Current Focus', 'visible-group': 'Visible Group', 'selected-docs': 'Selected Docs' }[mode]) || 'Ask SDTK-WIKI';
1508
+ return ({ 'all-docs': 'All Docs', 'current-focus': 'This doc + related', 'visible-group': 'All Docs', 'selected-docs': 'Selected docs' }[mode]) || 'Ask SDTK-WIKI';
1509
+ }
1510
+ function getWikiAskAllDocIds(pathFilter) {
1511
+ const filter = (pathFilter || '').trim().toLowerCase();
1512
+ return docs.filter(d => {
1513
+ if (!filter) return true;
1514
+ const docPath = (d.sourcePath || d.path || d.id || '').toLowerCase();
1515
+ return docPath.startsWith(filter);
1516
+ }).map(d => d.id);
1517
+ }
1518
+ function setWikiAskDocPathFilter(value) {
1519
+ wikiAskState.docPathFilter = value || '';
1520
+ renderWikiAsk();
1521
+ }
1522
+ function getWikiAskFolderList() {
1523
+ var counts = {};
1524
+ for (var i = 0; i < docs.length; i++) {
1525
+ var d = docs[i];
1526
+ var sp = (d.sourcePath || d.path || d.id || '').replace(/\\/g, '/');
1527
+ if (!sp) continue;
1528
+ var parts = sp.split('/');
1529
+ if (parts.length >= 2) {
1530
+ var prefix = parts.slice(0, 2).join('/') + '/';
1531
+ counts[prefix] = (counts[prefix] || 0) + 1;
1532
+ }
1533
+ }
1534
+ return Object.entries(counts)
1535
+ .sort(function(a, b) { return b[1] - a[1] || a[0].localeCompare(b[0]); })
1536
+ .slice(0, 8)
1537
+ .map(function(e) { return { path: e[0], count: e[1] }; });
1538
+ }
1539
+ function renderWikiAskFolderList() {
1540
+ var list = document.getElementById('ask-folder-list');
1541
+ if (!list) return;
1542
+ var folders = getWikiAskFolderList();
1543
+ if (!folders.length) {
1544
+ list.innerHTML = '<div style="padding:8px 10px;font-size:12px;color:#6d7d95">No folders found.</div>';
1545
+ return;
1546
+ }
1547
+ list.innerHTML = folders.map(function(f) {
1548
+ var active = wikiAskState.docPathFilter === f.path ? ' active' : '';
1549
+ return '<div class="ask-folder-item' + active + '" data-path="' + escapeHtml(f.path) + '" onclick="pickWikiAskFolder(' + JSON.stringify(f.path) + ')"><span class="f-path">' + escapeHtml(f.path) + '</span><span class="f-count">' + f.count + '</span></div>';
1550
+ }).join('');
1551
+ }
1552
+ function toggleWikiAskScopeMenu(event) {
1553
+ if (event) event.stopPropagation();
1554
+ var wrap = document.getElementById('ask-scope-wrap');
1555
+ if (!wrap) return;
1556
+ var isOpen = wrap.classList.contains('open');
1557
+ if (!isOpen) {
1558
+ renderWikiAskFolderList();
1559
+ syncWikiAskScopeMenu();
1560
+ }
1561
+ wrap.classList.toggle('open', !isOpen);
1562
+ var btn = document.getElementById('ask-scope-btn');
1563
+ if (btn) btn.setAttribute('aria-expanded', String(!isOpen));
1564
+ }
1565
+ function closeWikiAskScopeMenu() {
1566
+ var wrap = document.getElementById('ask-scope-wrap');
1567
+ if (wrap) {
1568
+ wrap.classList.remove('open');
1569
+ var btn = document.getElementById('ask-scope-btn');
1570
+ if (btn) btn.setAttribute('aria-expanded', 'false');
1571
+ }
1572
+ }
1573
+ function syncWikiAskScopeMenu() {
1574
+ var menu = document.getElementById('ask-scope-menu');
1575
+ if (!menu) return;
1576
+ menu.querySelectorAll('.ask-scope-opt').forEach(function(opt) { opt.classList.remove('active'); });
1577
+ var scope = wikiAskState.mode === 'current-focus' ? 'focus'
1578
+ : wikiAskState.mode === 'selected-docs' ? 'selected'
1579
+ : wikiAskState.docPathFilter ? 'folder'
1580
+ : 'all';
1581
+ var target = menu.querySelector('[data-scope="' + scope + '"]');
1582
+ if (target) target.classList.add('active');
1583
+ var focusOpt = document.getElementById('ask-scope-opt-focus');
1584
+ var focusId = typeof getGraphFocusId !== 'undefined' ? getGraphFocusId() : null;
1585
+ if (focusOpt) {
1586
+ if (focusId) { focusOpt.classList.remove('disabled'); focusOpt.removeAttribute('aria-disabled'); }
1587
+ else { focusOpt.classList.add('disabled'); focusOpt.setAttribute('aria-disabled', 'true'); }
1588
+ }
1589
+ var selectedOpt = document.getElementById('ask-scope-opt-selected');
1590
+ if (selectedOpt) {
1591
+ var selLabel = document.getElementById('ask-scope-selected-label');
1592
+ if (wikiAskState.selectedDocIds.length > 0) {
1593
+ selectedOpt.style.display = '';
1594
+ if (selLabel) selLabel.textContent = 'Selected docs (' + wikiAskState.selectedDocIds.length + ')';
1595
+ } else { selectedOpt.style.display = 'none'; }
1596
+ }
1597
+ if (scope === 'folder') {
1598
+ var folderBlock = document.getElementById('ask-folder-block');
1599
+ if (folderBlock) folderBlock.classList.add('open');
1600
+ document.querySelectorAll('#ask-folder-list .ask-folder-item').forEach(function(item) {
1601
+ item.classList.toggle('active', item.dataset.path === wikiAskState.docPathFilter);
1602
+ });
1603
+ }
1604
+ }
1605
+ function pickWikiAskScope(scope) {
1606
+ if (scope === 'focus') {
1607
+ var focusId = typeof getGraphFocusId !== 'undefined' ? getGraphFocusId() : null;
1608
+ if (!focusId) return;
1609
+ wikiAskState.mode = 'current-focus';
1610
+ wikiAskState.docPathFilter = '';
1611
+ } else if (scope === 'selected') {
1612
+ wikiAskState.mode = 'selected-docs';
1613
+ wikiAskState.docPathFilter = '';
1614
+ } else {
1615
+ wikiAskState.mode = 'all-docs';
1616
+ wikiAskState.docPathFilter = '';
1617
+ }
1618
+ closeWikiAskScopeMenu();
1619
+ renderWikiAsk();
1620
+ }
1621
+ function toggleWikiAskFolderBlock(event) {
1622
+ if (event) event.stopPropagation();
1623
+ var block = document.getElementById('ask-folder-block');
1624
+ if (!block) return;
1625
+ var isOpen = block.classList.contains('open');
1626
+ block.classList.toggle('open', !isOpen);
1627
+ if (!isOpen) { var filter = document.getElementById('ask-folder-filter'); if (filter) filter.focus(); }
1628
+ }
1629
+ function pickWikiAskFolder(path) {
1630
+ wikiAskState.mode = 'all-docs';
1631
+ wikiAskState.docPathFilter = path;
1632
+ document.querySelectorAll('#ask-folder-list .ask-folder-item').forEach(function(item) {
1633
+ item.classList.toggle('active', item.dataset.path === path);
1634
+ });
1635
+ closeWikiAskScopeMenu();
1636
+ renderWikiAsk();
1637
+ }
1638
+ function filterWikiAskFolders(q) {
1639
+ var query = (q || '').trim().toLowerCase();
1640
+ document.querySelectorAll('#ask-folder-list .ask-folder-item').forEach(function(item) {
1641
+ var p = item.dataset.path.toLowerCase();
1642
+ item.style.display = (!query || p.indexOf(query) !== -1) ? '' : 'none';
1643
+ });
1644
+ }
1645
+ function renderWikiAskSourcesPanel(context) {
1646
+ if (!context) return '';
1647
+ var sourcePaths = Array.isArray(context.source_paths) ? context.source_paths : [];
1648
+ var relationMap = context.truncated && typeof context.truncated === 'object' && context.truncated.relation ? context.truncated.relation : {};
1649
+ var total = sourcePaths.length;
1650
+ if (!total) return '';
1651
+ var rows = sourcePaths.map(function(p) {
1652
+ var rel = relationMap[p];
1653
+ var chipHtml;
1654
+ if (!rel || rel === 'primary') {
1655
+ chipHtml = '<span class="s-chip primary">primary</span>';
1656
+ } else {
1657
+ var facetsRaw = rel.replace(/^shares\s+/, '');
1658
+ var bkMatch = facetsRaw.match(/issue:([^,\s]+)/);
1659
+ var relLabel = bkMatch ? 'related \u00b7 ' + bkMatch[1] : 'related';
1660
+ chipHtml = '<span class="s-chip related">' + escapeHtml(relLabel) + '</span>';
1661
+ }
1662
+ return '<div class="ask-source-row"><span class="s-path">' + escapeHtml(p) + '</span>' + chipHtml + '</div>';
1663
+ }).join('');
1664
+ return '<div class="ask-sources"><button class="ask-sources-head" onclick="this.parentElement.classList.toggle(\'open\')" title="Toggle sources used panel"><span class="ask-sources-caret">&#9658;</span><span>Sources used</span><span class="ask-sources-badge">' + total + '</span></button><div class="ask-sources-body">' + rows + '</div></div>';
1437
1665
  }
1666
+
1438
1667
  function getWikiAskVisibleDocIds() {
1439
1668
  return getVisibleDocs().map(doc => doc.id);
1440
1669
  }
@@ -1569,7 +1798,8 @@ function loadWikiAskHistoryItem(id) {
1569
1798
  wikiAskState.question = '';
1570
1799
  wikiAskState.pendingQuestion = '';
1571
1800
  wikiAskState.lastQuestion = item.question || '';
1572
- wikiAskState.mode = item.mode || 'visible-group';
1801
+ wikiAskState.mode = (item.mode === 'visible-group' ? 'all-docs' : (item.mode || 'all-docs'));
1802
+ wikiAskState.docPathFilter = item.doc_path_filter || '';
1573
1803
  wikiAskState.selectedDocIds = Array.isArray(item.selected_doc_ids) ? [...item.selected_doc_ids] : [];
1574
1804
  wikiAskState.answer = {
1575
1805
  question: item.question || '',
@@ -1696,20 +1926,23 @@ function clearWikiAskSources() {
1696
1926
  renderWikiAsk();
1697
1927
  }
1698
1928
  function getWikiAskSourceSummaryHtml() {
1699
- const visibleCount = getWikiAskVisibleDocIds().length;
1700
1929
  const selectedCount = wikiAskState.selectedDocIds.length;
1701
1930
  const focusId = getGraphFocusId();
1702
- const focusLabel = focusId ? ((docById[focusId] && (docById[focusId].title || focusId)) || focusId) : 'None';
1931
+ const focusLabel = focusId ? ((docById[focusId] && (docById[focusId].title || focusId)) || focusId) : null;
1932
+ if (wikiAskState.mode === 'all-docs') {
1933
+ const filteredIds = getWikiAskAllDocIds(wikiAskState.docPathFilter);
1934
+ if (wikiAskState.docPathFilter) {
1935
+ return `<strong>Scope:</strong> By folder <code>${escapeHtml(wikiAskState.docPathFilter)}</code> — ${filteredIds.length} of ${docs.length} docs.`;
1936
+ }
1937
+ return `<strong>Scope:</strong> All Docs — ${filteredIds.length} docs indexed.`;
1938
+ }
1703
1939
  if (wikiAskState.mode === 'current-focus') {
1704
1940
  if (focusId) {
1705
- return `<strong>Grounding:</strong> Current focus source. <strong>Focus:</strong> ${escapeHtml(focusLabel)}. <strong>Visible group:</strong> ${visibleCount} docs.`;
1941
+ return `<strong>Scope:</strong> This doc + related — <strong>${escapeHtml(focusLabel)}</strong> and its related siblings.`;
1706
1942
  }
1707
- return `<strong>Grounding:</strong> Current focus source. Select a graph note to anchor this ask. <strong>Visible group:</strong> ${visibleCount} docs.`;
1708
- }
1709
- if (wikiAskState.mode === 'visible-group') {
1710
- return `<strong>Grounding:</strong> Visible graph group. <strong>Visible docs:</strong> ${visibleCount}.${selectedCount ? ` <strong>Explicit docs:</strong> ${selectedCount}.` : ''}`;
1943
+ return `<strong>Scope:</strong> This doc + related <em>focus a graph node first.</em>`;
1711
1944
  }
1712
- return `<strong>Grounding:</strong> Explicit source pack. <strong>Selected docs:</strong> ${selectedCount}.${selectedCount ? '' : ' Use graph search results or @mention docs before asking.'}`;
1945
+ return `<strong>Scope:</strong> Selected docs ${selectedCount} doc${selectedCount !== 1 ? 's' : ''} attached.${selectedCount ? '' : ' Attach docs via @mention or graph search.'}`;
1713
1946
  }
1714
1947
  function getWikiAskAdvancedSummaryText() {
1715
1948
  const bits = [];
@@ -1837,7 +2070,6 @@ function renderWikiAsk() {
1837
2070
  );
1838
2071
  const runtimeSelect = document.getElementById('atlas-ask-runtime-select');
1839
2072
  const modelSelect = document.getElementById('atlas-ask-model-select');
1840
- const modeSelect = document.getElementById('atlas-ask-mode-select');
1841
2073
  const input = document.getElementById('atlas-ask-input');
1842
2074
  const attachments = document.getElementById('atlas-ask-attachments');
1843
2075
  const sourceSummary = document.getElementById('atlas-ask-source-summary');
@@ -1857,7 +2089,24 @@ function renderWikiAsk() {
1857
2089
  modelSelect.innerHTML = modelOptions.map(model => `<option value="${model}">${escapeHtml(getWikiAskModelLabel(model))}</option>`).join('');
1858
2090
  if (modelSelect.value !== wikiAskState.model) modelSelect.value = wikiAskState.model;
1859
2091
  }
1860
- if (modeSelect && modeSelect.value !== wikiAskState.mode) modeSelect.value = wikiAskState.mode;
2092
+ const scopeIco = document.getElementById('ask-scope-ico');
2093
+ const scopeLbl = document.getElementById('ask-scope-label');
2094
+ if (scopeIco && scopeLbl) {
2095
+ if (wikiAskState.mode === 'current-focus') {
2096
+ scopeIco.textContent = '\u{1F3AF}';
2097
+ scopeLbl.textContent = 'This doc + related';
2098
+ } else if (wikiAskState.mode === 'selected-docs') {
2099
+ scopeIco.textContent = '\u2713';
2100
+ const selCnt = wikiAskState.selectedDocIds.length;
2101
+ scopeLbl.textContent = selCnt > 0 ? `Selected docs (${selCnt})` : 'Selected docs';
2102
+ } else if (wikiAskState.docPathFilter) {
2103
+ scopeIco.textContent = '\u{1F4C1}';
2104
+ scopeLbl.textContent = wikiAskState.docPathFilter;
2105
+ } else {
2106
+ scopeIco.textContent = '\u{1F4DA}';
2107
+ scopeLbl.textContent = 'All Docs';
2108
+ }
2109
+ }
1861
2110
  if (sourceSummary) sourceSummary.innerHTML = getWikiAskSourceSummaryHtml();
1862
2111
  if (mentionResults) renderWikiAskMentions();
1863
2112
  if (attachments) {
@@ -1903,7 +2152,8 @@ function renderWikiAsk() {
1903
2152
  ? `<ul>${turn.citations.map(item => `<li><button class="wiki-link" onclick="focusGraphNode('${item.path}')">${escapeHtml(item.title || item.path)}</button> &middot; ${escapeHtml(item.reason || 'Grounded source')}</li>`).join('')}</ul>`
1904
2153
  : '<p class="muted">No citations were returned.</p>';
1905
2154
  blocks.push(`<div class="atlas-ask-message user"><div class="atlas-ask-message-label">You</div><div class="atlas-ask-message-bubble">${escapeHtml(turn.question || '')}</div></div>`);
1906
- blocks.push(`<div class="atlas-ask-message assistant"><div class="atlas-ask-message-label">SDTK-WIKI Ask</div><div class="atlas-ask-answer-card"><div class="graph-note-prose">${renderMarkdownFragment(turn.answer_markdown || '')}</div>${turn.disclaimer ? `<span class="muted">${escapeHtml(turn.disclaimer)}</span>` : ''}</div><div class="atlas-ask-answer-card"><h3>Citations</h3>${citationHtml}</div></div>`);
2155
+ const sourcesHtml = renderWikiAskSourcesPanel(turn.context || null);
2156
+ blocks.push(`<div class="atlas-ask-message assistant"><div class="atlas-ask-message-label">SDTK-WIKI Ask</div><div class="atlas-ask-answer-card"><div class="graph-note-prose">${renderMarkdownFragment(turn.answer_markdown || '')}</div>${turn.disclaimer ? `<span class="muted">${escapeHtml(turn.disclaimer)}</span>` : ''}</div><div class="atlas-ask-answer-card"><h3>Citations</h3>${citationHtml}</div>${sourcesHtml}</div>`);
1907
2157
  });
1908
2158
 
1909
2159
  if (wikiAskState.pendingQuestion) {
@@ -1912,7 +2162,7 @@ function renderWikiAsk() {
1912
2162
  }
1913
2163
 
1914
2164
  if (!blocks.length) {
1915
- answer.innerHTML = '<div class="atlas-ask-empty">Ask SDTK-WIKI from the visible graph group by default, or narrow the source pack with a selected node or attached docs.</div>';
2165
+ answer.innerHTML = '<div class="atlas-ask-empty">Use <strong>Ask within</strong> to choose your scope: All Docs for full coverage, By folder to narrow by path, This doc + related to focus on the current graph node, or Selected docs for pinned sources.</div>';
1916
2166
  return;
1917
2167
  }
1918
2168
 
@@ -1930,14 +2180,18 @@ async function submitWikiAsk() {
1930
2180
  renderWikiAsk();
1931
2181
  return;
1932
2182
  }
2183
+ const allDocIds = wikiAskState.mode === 'all-docs'
2184
+ ? getWikiAskAllDocIds(wikiAskState.docPathFilter)
2185
+ : getWikiAskVisibleDocIds();
1933
2186
  const payload = {
1934
2187
  question: draftQuestion,
1935
2188
  mode: wikiAskState.mode,
1936
2189
  runtime_agent: wikiAskState.runtimeAgent,
1937
2190
  model: wikiAskState.model,
1938
2191
  focus_doc_id: getGraphFocusId() || '',
1939
- visible_doc_ids: getWikiAskVisibleDocIds(),
2192
+ visible_doc_ids: allDocIds,
1940
2193
  selected_doc_ids: [...wikiAskState.selectedDocIds],
2194
+ doc_path_filter: wikiAskState.mode === 'all-docs' ? (wikiAskState.docPathFilter || '') : '',
1941
2195
  };
1942
2196
  wikiAskState.pendingQuestion = draftQuestion;
1943
2197
  wikiAskState.question = '';
@@ -2416,6 +2670,10 @@ document.addEventListener('mousedown', event => {
2416
2670
  graphState.settingsOpen = false;
2417
2671
  syncGraphSettings();
2418
2672
  }
2673
+ const scopeWrap = document.getElementById('ask-scope-wrap');
2674
+ if (scopeWrap && scopeWrap.classList.contains('open') && !scopeWrap.contains(event.target)) {
2675
+ closeWikiAskScopeMenu();
2676
+ }
2419
2677
  });
2420
2678
  window.addEventListener('mousemove', dragWikiAsk);
2421
2679
  window.addEventListener('mouseup', stopWikiAskDrag);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sdtk-wiki-kit",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Project-local wiki and knowledge graph toolkit for SDTK workspaces.",
5
5
  "bin": {
6
6
  "sdtk-wiki": "bin/sdtk-wiki.js"
@@ -57,6 +57,8 @@ Inputs:
57
57
  Behavior:
58
58
  Executes a local premium wiki.ask runtime pack when entitlement and graph preconditions pass.
59
59
  Fails closed when the graph, entitlement, or runtime pack is missing.
60
+ --source grounds on the chosen doc plus its directly-related siblings (docs sharing
61
+ the same BK issue, knowledge id, skill, or family), bounded and labelled as related.
60
62
  Query history is off by default.
61
63
  --save-query writes one redacted JSON record under .sdtk/wiki/queries after a successful answer.
62
64
  Full question and full answer are not stored by default.`);
@@ -32,7 +32,7 @@ BK-102 behavior:
32
32
  Options:
33
33
  --project-path <path> Project root. Defaults to current working directory.
34
34
  --output-dir <path> Graph output dir under .sdtk/wiki. Defaults to .sdtk/wiki/graph.
35
- --scan-root <path> Repeatable markdown scan root.
35
+ --scan-root <path> Repeatable markdown scan root. Defaults to ./docs if it exists, otherwise the project root.
36
36
  --force Overwrite existing config.
37
37
  --no-build Write config only.
38
38
  --no-open Build graph but do not launch viewer.
@@ -9,6 +9,11 @@ const { loadWikiAskHandler } = require("./wiki-premium-loader");
9
9
  const INDEX_FILE = "SDTK_DOC_INDEX.json";
10
10
  const GRAPH_FILE = "SDTK_DOC_GRAPH.json";
11
11
  const DEFAULT_MAX_SOURCES = 6;
12
+ // BK-269 hyperedge-aware context expansion (--source filter only).
13
+ // Mirrors run_doc_atlas_server.py so both grounding surfaces behave identically.
14
+ const DEFAULT_MAX_HYPEREDGE_EXPANSION = 6;
15
+ const DEFAULT_MIN_HYPEREDGE_CO_MEMBERSHIP = 1;
16
+ const DEFAULT_MAX_FAMILY_HYPEREDGE_CONTRIB = 3;
12
17
 
13
18
  function readJsonFile(filePath, label) {
14
19
  try {
@@ -69,28 +74,251 @@ function extractDocumentText(document) {
69
74
  return "";
70
75
  }
71
76
 
72
- function buildSources(index, sourceFilters, maxSources) {
77
+ function docId(document) {
78
+ return document.id || extractDocumentPath(document);
79
+ }
80
+
81
+ function docFacetKeys(document) {
82
+ const keys = [];
83
+ const groups = [
84
+ ["issue", document.issues],
85
+ ["knowledge", document.knowledge_ids],
86
+ ["skill", document.skill_refs],
87
+ ["lane", document.lane_refs],
88
+ ];
89
+ for (const [namespace, values] of groups) {
90
+ if (!Array.isArray(values)) {
91
+ continue;
92
+ }
93
+ for (const value of values) {
94
+ const text = String(value == null ? "" : value).trim();
95
+ if (text) {
96
+ keys.push(`${namespace}:${text}`);
97
+ }
98
+ }
99
+ }
100
+ const family = String(document.family == null ? "" : document.family).trim();
101
+ if (family) {
102
+ keys.push(`family:${family}`);
103
+ }
104
+ return keys;
105
+ }
106
+
107
+ // Invert shared reference facets into hyperedges (groups with >=2 docs). Pure and
108
+ // deterministic; mirrors _derive_hyperedges in run_doc_atlas_server.py.
109
+ function deriveHyperedges(documents) {
110
+ const facetToDocs = new Map();
111
+ for (const document of documents) {
112
+ if (!document || typeof document !== "object") {
113
+ continue;
114
+ }
115
+ const id = docId(document);
116
+ if (!id) {
117
+ continue;
118
+ }
119
+ for (const facetKey of docFacetKeys(document)) {
120
+ let members = facetToDocs.get(facetKey);
121
+ if (!members) {
122
+ members = [];
123
+ facetToDocs.set(facetKey, members);
124
+ }
125
+ if (!members.includes(id)) {
126
+ members.push(id);
127
+ }
128
+ }
129
+ }
130
+ const hyperedges = new Map();
131
+ for (const [facet, ids] of facetToDocs) {
132
+ if (ids.length >= 2) {
133
+ hyperedges.set(facet, ids.slice().sort());
134
+ }
135
+ }
136
+ const docHyperedges = new Map();
137
+ for (const [facet, ids] of hyperedges) {
138
+ for (const id of ids) {
139
+ let facets = docHyperedges.get(id);
140
+ if (!facets) {
141
+ facets = new Set();
142
+ docHyperedges.set(id, facets);
143
+ }
144
+ facets.add(facet);
145
+ }
146
+ }
147
+ return { hyperedges, docHyperedges };
148
+ }
149
+
150
+ function computeNodeDegree(graph, docIdSet) {
151
+ const degree = new Map();
152
+ const edges = graph && Array.isArray(graph.edges) ? graph.edges : [];
153
+ for (const edge of edges) {
154
+ const source = edge && edge.source;
155
+ const target = edge && edge.target;
156
+ if (docIdSet.has(source) && docIdSet.has(target)) {
157
+ degree.set(source, (degree.get(source) || 0) + 1);
158
+ degree.set(target, (degree.get(target) || 0) + 1);
159
+ }
160
+ }
161
+ return degree;
162
+ }
163
+
164
+ // Bounded hyperedge co-member expansion. Returns ranked expansion entries
165
+ // ({ id, facets, relation }). Mirrors _expand_via_hyperedges in the Python server:
166
+ // shared-facet-count desc -> node_degree desc -> title/id lexical, with a family cap.
167
+ function expandSourcesViaHyperedges(seedIds, derived, nodeDegree, docsById, options) {
168
+ const opts = options || {};
169
+ const maxExpansion = Number.isInteger(opts.maxExpansion)
170
+ ? opts.maxExpansion
171
+ : DEFAULT_MAX_HYPEREDGE_EXPANSION;
172
+ const minCoMembership = Number.isInteger(opts.minCoMembership)
173
+ ? opts.minCoMembership
174
+ : DEFAULT_MIN_HYPEREDGE_CO_MEMBERSHIP;
175
+ const familyCap = Number.isInteger(opts.familyCap)
176
+ ? opts.familyCap
177
+ : DEFAULT_MAX_FAMILY_HYPEREDGE_CONTRIB;
178
+ if (maxExpansion <= 0) {
179
+ return [];
180
+ }
181
+ const seedSet = new Set(seedIds);
182
+ const seedFacets = new Set();
183
+ for (const seedId of seedIds) {
184
+ const facets = derived.docHyperedges.get(seedId);
185
+ if (facets) {
186
+ for (const facet of facets) {
187
+ seedFacets.add(facet);
188
+ }
189
+ }
190
+ }
191
+ if (seedFacets.size === 0) {
192
+ return [];
193
+ }
194
+ const candidateFacets = new Map();
195
+ for (const facet of seedFacets) {
196
+ const members = derived.hyperedges.get(facet) || [];
197
+ for (const id of members) {
198
+ if (seedSet.has(id)) {
199
+ continue;
200
+ }
201
+ let facets = candidateFacets.get(id);
202
+ if (!facets) {
203
+ facets = new Set();
204
+ candidateFacets.set(id, facets);
205
+ }
206
+ facets.add(facet);
207
+ }
208
+ }
209
+ const candidates = [];
210
+ for (const [id, facets] of candidateFacets) {
211
+ if (facets.size >= minCoMembership) {
212
+ candidates.push({ id, facets });
213
+ }
214
+ }
215
+ candidates.sort((a, b) => {
216
+ if (b.facets.size !== a.facets.size) {
217
+ return b.facets.size - a.facets.size;
218
+ }
219
+ const degreeDelta = (nodeDegree.get(b.id) || 0) - (nodeDegree.get(a.id) || 0);
220
+ if (degreeDelta !== 0) {
221
+ return degreeDelta;
222
+ }
223
+ const titleA = String((docsById.get(a.id) || {}).title || a.id).toLowerCase();
224
+ const titleB = String((docsById.get(b.id) || {}).title || b.id).toLowerCase();
225
+ if (titleA !== titleB) {
226
+ return titleA < titleB ? -1 : 1;
227
+ }
228
+ const idA = a.id.toLowerCase();
229
+ const idB = b.id.toLowerCase();
230
+ if (idA !== idB) {
231
+ return idA < idB ? -1 : 1;
232
+ }
233
+ return 0;
234
+ });
235
+ const selected = [];
236
+ let familyOnlyAdmitted = 0;
237
+ for (const candidate of candidates) {
238
+ if (selected.length >= maxExpansion) {
239
+ break;
240
+ }
241
+ let hasNonFamily = false;
242
+ for (const facet of candidate.facets) {
243
+ if (!facet.startsWith("family:")) {
244
+ hasNonFamily = true;
245
+ break;
246
+ }
247
+ }
248
+ if (!hasNonFamily) {
249
+ // OQ-C: cap how many docs can enter via a family facet alone.
250
+ if (familyOnlyAdmitted >= familyCap) {
251
+ continue;
252
+ }
253
+ familyOnlyAdmitted += 1;
254
+ }
255
+ const sortedFacets = Array.from(candidate.facets).sort();
256
+ selected.push({
257
+ id: candidate.id,
258
+ facets: sortedFacets,
259
+ relation: `shares ${sortedFacets.join(", ")}`,
260
+ });
261
+ }
262
+ return selected;
263
+ }
264
+
265
+ function buildSources(index, sourceFilters, maxSources, graph) {
73
266
  const documents = Array.isArray(index.documents) ? index.documents : [];
74
267
  const filters = (sourceFilters || []).map((item) => item.trim()).filter(Boolean);
75
268
  const limit = Number.isInteger(maxSources) && maxSources > 0 ? maxSources : DEFAULT_MAX_SOURCES;
76
269
 
77
- return documents
78
- .map((document) => {
79
- const sourcePath = extractDocumentPath(document);
80
- return {
81
- id: document.id || sourcePath,
82
- path: sourcePath,
83
- title: document.title || sourcePath,
84
- text: extractDocumentText(document),
85
- };
86
- })
87
- .filter((source) => {
88
- if (filters.length === 0) {
89
- return true;
90
- }
91
- return filters.some((filter) => source.id === filter || source.path === filter);
92
- })
93
- .slice(0, limit);
270
+ const mapped = documents.map((document) => {
271
+ const sourcePath = extractDocumentPath(document);
272
+ return {
273
+ id: document.id || sourcePath,
274
+ path: sourcePath,
275
+ title: document.title || sourcePath,
276
+ text: extractDocumentText(document),
277
+ relation: "primary",
278
+ };
279
+ });
280
+
281
+ // No explicit --source filter: ground on all docs, unchanged (no expansion).
282
+ if (filters.length === 0) {
283
+ return mapped.slice(0, limit);
284
+ }
285
+
286
+ const primaries = mapped.filter((source) =>
287
+ filters.some((filter) => source.id === filter || source.path === filter)
288
+ );
289
+
290
+ // BK-269: append the filtered docs' hyperedge co-members (capped, labelled),
291
+ // so `--source <doc>` also grounds on its directly-related siblings.
292
+ const sourceById = new Map(mapped.map((source) => [source.id, source]));
293
+ const docsById = new Map();
294
+ for (const document of documents) {
295
+ if (!document || typeof document !== "object") {
296
+ continue;
297
+ }
298
+ const id = docId(document);
299
+ if (id && !docsById.has(id)) {
300
+ docsById.set(id, document);
301
+ }
302
+ }
303
+ const derived = deriveHyperedges(documents);
304
+ const nodeDegree = computeNodeDegree(graph, new Set(docsById.keys()));
305
+ const seedIds = primaries.map((source) => source.id);
306
+ const expansion = expandSourcesViaHyperedges(seedIds, derived, nodeDegree, docsById, {});
307
+
308
+ const result = primaries.slice();
309
+ const seen = new Set(seedIds);
310
+ for (const item of expansion) {
311
+ if (seen.has(item.id)) {
312
+ continue;
313
+ }
314
+ const base = sourceById.get(item.id);
315
+ if (!base) {
316
+ continue;
317
+ }
318
+ seen.add(item.id);
319
+ result.push({ ...base, relation: item.relation });
320
+ }
321
+ return result.slice(0, limit);
94
322
  }
95
323
 
96
324
  function normalizeCitations(citations) {
@@ -126,6 +354,7 @@ function normalizeAskResult(rawResult, context) {
126
354
  citations: normalizeCitations(result.citations),
127
355
  confidence: result.confidence,
128
356
  graphPath: context.graphPath,
357
+ expandedDocCount: Number.isInteger(context.expandedDocCount) ? context.expandedDocCount : 0,
129
358
  };
130
359
  }
131
360
 
@@ -139,7 +368,10 @@ async function runWikiAsk(options) {
139
368
  const graphInfo = assertWikiGraphReady(projectPath);
140
369
  const index = readJsonFile(graphInfo.indexPath, INDEX_FILE);
141
370
  const graph = readJsonFile(graphInfo.graphFilePath, GRAPH_FILE);
142
- const sources = buildSources(index, options.sources, options.maxSources);
371
+ const sources = buildSources(index, options.sources, options.maxSources, graph);
372
+ const expandedDocCount = sources.filter(
373
+ (source) => source.relation && source.relation !== "primary"
374
+ ).length;
143
375
 
144
376
  const handlerState = await loadWikiAskHandler();
145
377
  if (!handlerState.ok) {
@@ -155,6 +387,7 @@ async function runWikiAsk(options) {
155
387
  graphFilePath: graphInfo.graphFilePath,
156
388
  graph,
157
389
  sources,
390
+ expandedDocCount,
158
391
  maxSources: options.maxSources,
159
392
  };
160
393
 
@@ -170,6 +403,8 @@ async function runWikiAsk(options) {
170
403
 
171
404
  module.exports = {
172
405
  buildSources,
406
+ deriveHyperedges,
407
+ expandSourcesViaHyperedges,
173
408
  normalizeAskResult,
174
409
  runWikiAsk,
175
410
  };
@@ -71,7 +71,9 @@ function resolveWikiConfig(flags = {}) {
71
71
  ) {
72
72
  scanRoots = persisted.scanRoots.map((r) => path.resolve(r));
73
73
  } else {
74
- scanRoots = [projectPath];
74
+ const docsDir = path.join(projectPath, "docs");
75
+ const hasDocsDir = fs.existsSync(docsDir) && fs.statSync(docsDir).isDirectory();
76
+ scanRoots = [hasDocsDir ? docsDir : projectPath];
75
77
  }
76
78
 
77
79
  const excludes =