@oss-scout/core 0.9.1 → 0.10.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/dist/cli.js CHANGED
@@ -89,6 +89,8 @@ program
89
89
  .description("Search for contributable issues using multi-strategy discovery")
90
90
  .option("--json", "Output as JSON")
91
91
  .option("--strategy <strategies>", `Search strategies (${CONCRETE_STRATEGIES.join(",")},all)`, "all")
92
+ .option("--prefer-languages <list>", "Comma-separated languages to soft-boost in ranking (#1244). Candidates whose repo language matches sort above equally-recommended non-matches. Does not filter results.")
93
+ .option("--prefer-repos <list>", "Comma-separated `owner/repo` slugs to soft-boost in ranking (#1244). Stronger weight than language match. Does not filter results.")
92
94
  .action(async (count, options) => {
93
95
  try {
94
96
  if (!hasLocalState()) {
@@ -124,7 +126,22 @@ program
124
126
  }
125
127
  strategies.push(parsed.data);
126
128
  }
127
- const results = await runSearch({ maxResults, state, strategies });
129
+ const splitCsv = (raw) => {
130
+ if (!raw)
131
+ return undefined;
132
+ const parts = raw
133
+ .split(",")
134
+ .map((s) => s.trim())
135
+ .filter(Boolean);
136
+ return parts.length > 0 ? parts : undefined;
137
+ };
138
+ const results = await runSearch({
139
+ maxResults,
140
+ state,
141
+ strategies,
142
+ preferLanguages: splitCsv(options.preferLanguages),
143
+ preferRepos: splitCsv(options.preferRepos),
144
+ });
128
145
  if (options.json) {
129
146
  console.log(formatJsonSuccess(results));
130
147
  }
@@ -37,6 +37,14 @@ export interface SearchOutput {
37
37
  updatedAt?: string;
38
38
  isStalled: boolean;
39
39
  };
40
+ /**
41
+ * Personalization sort-tier signal (#1244). Present only when the
42
+ * caller passed `preferLanguages` / `preferRepos` *and* this
43
+ * candidate matched at least one of them. `boostReasons` is the
44
+ * human-readable explanation (e.g. `"repo affinity: vercel/next.js"`).
45
+ */
46
+ boostScore?: number;
47
+ boostReasons?: string[];
40
48
  }>;
41
49
  excludedRepos: string[];
42
50
  aiPolicyBlocklist: string[];
@@ -47,6 +55,10 @@ interface SearchCommandOptions {
47
55
  maxResults: number;
48
56
  state?: ScoutState;
49
57
  strategies?: SearchStrategy[];
58
+ /** Soft sort boost for candidates whose repo language matches (#1244). */
59
+ preferLanguages?: string[];
60
+ /** Soft sort boost for candidates in these `owner/repo` slugs (#1244). */
61
+ preferRepos?: string[];
50
62
  }
51
63
  export declare function runSearch(options: SearchCommandOptions): Promise<SearchOutput>;
52
64
  export {};
@@ -17,6 +17,8 @@ export async function runSearch(options) {
17
17
  const result = await scout.search({
18
18
  maxResults: options.maxResults,
19
19
  strategies: options.strategies,
20
+ preferLanguages: options.preferLanguages,
21
+ preferRepos: options.preferRepos,
20
22
  });
21
23
  // Persist results to local state and gist
22
24
  scout.saveResults(result.candidates);
@@ -60,6 +62,8 @@ export async function runSearch(options) {
60
62
  isStalled: isLinkedPRStalled(c.vettingResult.linkedPR),
61
63
  }
62
64
  : undefined,
65
+ boostScore: c.boostScore,
66
+ boostReasons: c.boostReasons,
63
67
  };
64
68
  }),
65
69
  excludedRepos: result.excludedRepos,
@@ -74,6 +74,8 @@ export declare class IssueDiscovery {
74
74
  maxResults?: number;
75
75
  strategies?: SearchStrategy[];
76
76
  skippedUrls?: Set<string>;
77
+ preferLanguages?: string[];
78
+ preferRepos?: string[];
77
79
  }): Promise<{
78
80
  candidates: IssueCandidate[];
79
81
  strategiesUsed: SearchStrategy[];
@@ -22,6 +22,7 @@ import { isDocOnlyIssue, applyPerRepoCap, } from "./issue-filtering.js";
22
22
  import { IssueVetter } from "./issue-vetting.js";
23
23
  import { getTopicsForCategories } from "./category-mapping.js";
24
24
  import { buildEffectiveLabels, interleaveArrays, cachedSearchIssues, fetchIssuesFromMaintainedRepos, filterVetAndScore, fetchIssuesFromKnownRepos, searchAcrossLanguagesAndLabels, } from "./search-phases.js";
25
+ import { annotateBoost } from "./personalization.js";
25
26
  const MODULE = "issue-discovery";
26
27
  /** If remaining search quota is below this, skip heavy phases (2, 3). */
27
28
  const LOW_BUDGET_THRESHOLD = 20;
@@ -486,7 +487,11 @@ export class IssueDiscovery {
486
487
  `Found ${allCandidates.length} candidate${allCandidates.length === 1 ? "" : "s"} but some search phases were limited. ` +
487
488
  `Try again after the rate limit resets for complete results.`;
488
489
  }
489
- // Sort by priority, recommendation, then viability score
490
+ // Personalization annotation (#1244): tag each candidate with
491
+ // boostScore + boostReasons before sorting so the new sort tier has
492
+ // values to read. No-op when neither preference list is supplied.
493
+ annotateBoost(allCandidates, options.preferLanguages, options.preferRepos);
494
+ // Sort by priority, recommendation, boost (#1244), then viability score
490
495
  allCandidates.sort((a, b) => {
491
496
  const priorityOrder = {
492
497
  merged_pr: 0,
@@ -501,6 +506,14 @@ export class IssueDiscovery {
501
506
  recommendationOrder[b.recommendation];
502
507
  if (recDiff !== 0)
503
508
  return recDiff;
509
+ // Personalization tier (#1244): higher boostScore wins. Treats
510
+ // undefined as 0 so unboosted candidates rank below boosted peers
511
+ // but stay ordered among themselves by viabilityScore. No-op when
512
+ // `preferLanguages`/`preferRepos` are absent — all candidates carry
513
+ // `boostScore: undefined` and the difference collapses to 0.
514
+ const boostDiff = (b.boostScore ?? 0) - (a.boostScore ?? 0);
515
+ if (boostDiff !== 0)
516
+ return boostDiff;
504
517
  return b.viabilityScore - a.viabilityScore;
505
518
  });
506
519
  const capped = applyPerRepoCap(allCandidates, 2);
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Personalization signals for search ranking (#1244).
3
+ *
4
+ * Translates caller-supplied `preferLanguages` / `preferRepos` lists
5
+ * into a soft `boostScore` on each `IssueCandidate`. The final search
6
+ * sort consults this score between the `recommendation` tier and the
7
+ * raw `viabilityScore`, so personalization reorders ties without
8
+ * changing which candidates pass vetting.
9
+ *
10
+ * This is the minimum-viable subset of Option A in #1244: only language
11
+ * and repo bias, no `boostIssueTypes` / `avoidRepos` / `diversityRatio`
12
+ * yet. Those follow up in separate PRs.
13
+ */
14
+ import type { IssueCandidate } from "./types.js";
15
+ /**
16
+ * Boost weights. Tuned conservatively so personalization tips equally-
17
+ * scored candidates without drowning out high-viability normal results.
18
+ *
19
+ * Rationale:
20
+ * - Repo affinity is the strongest signal — a candidate in a repo the
21
+ * user has merged PRs into has real relationship context. Worth the
22
+ * higher boost.
23
+ * - Language match is broad and easy to satisfy. Lower weight.
24
+ */
25
+ export declare const REPO_BOOST = 20;
26
+ export declare const LANGUAGE_BOOST = 10;
27
+ /**
28
+ * Annotate each candidate with `boostScore` and `boostReasons` based on
29
+ * the caller-supplied preference lists. Mutates the array in place; the
30
+ * caller is responsible for re-sorting afterwards.
31
+ *
32
+ * Mutation (rather than returning new objects) keeps the personalization
33
+ * step a single linear pass over the array the caller already holds —
34
+ * the sort step reads back from the same objects.
35
+ *
36
+ * No-op when both preference lists are empty or undefined: candidates
37
+ * retain `boostScore: undefined` and the sort tier collapses to 0.
38
+ */
39
+ export declare function annotateBoost(candidates: IssueCandidate[], preferLanguages?: string[], preferRepos?: string[]): void;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Personalization signals for search ranking (#1244).
3
+ *
4
+ * Translates caller-supplied `preferLanguages` / `preferRepos` lists
5
+ * into a soft `boostScore` on each `IssueCandidate`. The final search
6
+ * sort consults this score between the `recommendation` tier and the
7
+ * raw `viabilityScore`, so personalization reorders ties without
8
+ * changing which candidates pass vetting.
9
+ *
10
+ * This is the minimum-viable subset of Option A in #1244: only language
11
+ * and repo bias, no `boostIssueTypes` / `avoidRepos` / `diversityRatio`
12
+ * yet. Those follow up in separate PRs.
13
+ */
14
+ /**
15
+ * Boost weights. Tuned conservatively so personalization tips equally-
16
+ * scored candidates without drowning out high-viability normal results.
17
+ *
18
+ * Rationale:
19
+ * - Repo affinity is the strongest signal — a candidate in a repo the
20
+ * user has merged PRs into has real relationship context. Worth the
21
+ * higher boost.
22
+ * - Language match is broad and easy to satisfy. Lower weight.
23
+ */
24
+ export const REPO_BOOST = 20;
25
+ export const LANGUAGE_BOOST = 10;
26
+ /**
27
+ * Annotate each candidate with `boostScore` and `boostReasons` based on
28
+ * the caller-supplied preference lists. Mutates the array in place; the
29
+ * caller is responsible for re-sorting afterwards.
30
+ *
31
+ * Mutation (rather than returning new objects) keeps the personalization
32
+ * step a single linear pass over the array the caller already holds —
33
+ * the sort step reads back from the same objects.
34
+ *
35
+ * No-op when both preference lists are empty or undefined: candidates
36
+ * retain `boostScore: undefined` and the sort tier collapses to 0.
37
+ */
38
+ export function annotateBoost(candidates, preferLanguages, preferRepos) {
39
+ const langSet = new Set((preferLanguages ?? []).map((l) => l.trim().toLowerCase()).filter(Boolean));
40
+ const repoSet = new Set((preferRepos ?? []).map((r) => r.trim()).filter(Boolean));
41
+ if (langSet.size === 0 && repoSet.size === 0)
42
+ return;
43
+ for (const c of candidates) {
44
+ let score = 0;
45
+ const reasons = [];
46
+ if (repoSet.size > 0 && repoSet.has(c.issue.repo)) {
47
+ score += REPO_BOOST;
48
+ reasons.push(`repo affinity: ${c.issue.repo}`);
49
+ }
50
+ const lang = c.projectHealth.language;
51
+ if (langSet.size > 0 && lang && langSet.has(lang.toLowerCase())) {
52
+ score += LANGUAGE_BOOST;
53
+ reasons.push(`language match: ${lang}`);
54
+ }
55
+ if (score > 0) {
56
+ c.boostScore = score;
57
+ c.boostReasons = reasons;
58
+ }
59
+ }
60
+ }
@@ -53,6 +53,19 @@ export interface IssueCandidate {
53
53
  reasonsToApprove: string[];
54
54
  viabilityScore: number;
55
55
  searchPriority: SearchPriority;
56
+ /**
57
+ * Personalization sort tier (#1244). Populated only when the caller
58
+ * passes `preferLanguages` / `preferRepos` to `search()` *and* the
59
+ * candidate matches at least one. Affects sort order between the
60
+ * `recommendation` tier and `viabilityScore`; never used as a filter.
61
+ */
62
+ boostScore?: number;
63
+ /**
64
+ * Human-readable reasons the candidate matched personalization bias
65
+ * (#1244). Mirrors `reasonsToApprove`/`reasonsToSkip` shape for
66
+ * symmetry with the existing surface.
67
+ */
68
+ boostReasons?: string[];
56
69
  }
57
70
  /** Subset of RepoScore fields that callers may update. */
58
71
  export interface RepoScoreUpdate {
@@ -122,6 +135,21 @@ export type ScoutConfig = {
122
135
  export interface SearchOptions {
123
136
  maxResults?: number;
124
137
  strategies?: SearchStrategy[];
138
+ /**
139
+ * Per-call personalization bias: candidates whose repo language matches
140
+ * one of these (case-insensitive) get a soft sort boost above
141
+ * equally-recommended non-matches (#1244). Does not filter results, does
142
+ * not change `viabilityScore`. Empty / undefined disables the boost.
143
+ */
144
+ preferLanguages?: string[];
145
+ /**
146
+ * Per-call personalization bias: candidates in one of these
147
+ * `owner/repo` slugs get a soft sort boost above equally-recommended
148
+ * non-matches (#1244). Stronger weight than language match. Does not
149
+ * filter results, does not change `viabilityScore`. Empty / undefined
150
+ * disables the boost.
151
+ */
152
+ preferRepos?: string[];
125
153
  }
126
154
  /** Result of a search operation. */
127
155
  export interface SearchResult {
package/dist/scout.js CHANGED
@@ -148,6 +148,8 @@ export class OssScout {
148
148
  maxResults: options?.maxResults,
149
149
  strategies: options?.strategies,
150
150
  skippedUrls,
151
+ preferLanguages: options?.preferLanguages,
152
+ preferRepos: options?.preferRepos,
151
153
  });
152
154
  this.state.lastSearchAt = new Date().toISOString();
153
155
  this.dirty = true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oss-scout/core",
3
- "version": "0.9.1",
3
+ "version": "0.10.0",
4
4
  "description": "Personalized GitHub issue finder with multi-strategy search, deep vetting, and viability scoring — CLI, library, MCP server, and Claude Code plugin",
5
5
  "type": "module",
6
6
  "bin": {