relay-dsh-plugin-manager 0.2.6 → 0.3.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/lib/index.js CHANGED
@@ -74,13 +74,15 @@ function registerConversationSurface(ctx, manager) {
74
74
  const confirmations = /* @__PURE__ */ new Map();
75
75
  ctx.tools.register(defineTool({
76
76
  name: "plugin_discover",
77
- description: "Read-only DSH plugin discovery. List installed plugins, search registered sources (including GitHub owner:NAME), inspect one npm/GitHub repository source, or query plugin/operation status. Search candidates form one relevance-ranked result page: present every possibly relevant candidate in ascending rank, exclude candidates whose purpose is clearly unrelated, and NEVER silently truncate the remaining page to a fixed top-N. Ranking is not a compatibility, security, or installation approval. Search result repository and recommendedSource values can be passed directly to inspect and plan. This tool never changes the profile.",
77
+ description: "Read-only DSH plugin discovery. Use search for one capability, exact identity, or GitHub owner:NAME. For a task needing multiple responsibilities, use search_roles: decompose the task into the smallest set of mutually distinct required/optional coverage roles, even when one plugin may ultimately cover several roles. Keep coherent operations on the same resource together: browsing, filtering, and previewing files are one file-browser responsibility. Every ongoing observation that finishes later is the deliberate exception and needs separate roles for source-specific state/event reading and for durable waiting/scheduling that resumes the original session; never merge those coverage responsibilities. Give each role a focused capability query and declare unresolved user choices as ambiguities. It returns inspected candidates grouped by role but does not claim they are relevant until reviewed. Exclude unrelated candidates, retain materially different alternatives, then call assess_solution with only reviewed candidate identities from each role. Assessment verifies role membership, merges one solution used by multiple roles, and reports the smallest complete answer only when every required role is selected and no ambiguity remains; never repeat npm/GitHub aliases, pad results, or silently impose a fixed top count. Ranking and directory placement are relevance evidence, not compatibility, security, or installation approval. Search result repository and recommendedSource values can be passed directly to inspect and plan. This tool never changes the profile.",
78
78
  parameters: {
79
79
  action: {
80
80
  type: "string",
81
81
  enum: [
82
82
  "list",
83
83
  "search",
84
+ "search_roles",
85
+ "assess_solution",
84
86
  "inspect",
85
87
  "status"
86
88
  ],
@@ -89,7 +91,7 @@ function registerConversationSurface(ctx, manager) {
89
91
  },
90
92
  query: {
91
93
  type: "string",
92
- description: "Natural-language query or GitHub owner:NAME search."
94
+ description: "Natural-language query or GitHub owner:NAME search. Required for search and search_roles; for search_roles this is the complete user task and must contain 1 to 1000 printable characters."
93
95
  },
94
96
  target: {
95
97
  type: "string",
@@ -102,6 +104,85 @@ function registerConversationSurface(ctx, manager) {
102
104
  maxResults: {
103
105
  type: "integer",
104
106
  description: "Ranked result-page size from 1 to 20. Use 20 for ordinary need-based searches unless the user explicitly asks for fewer."
107
+ },
108
+ maxResultsPerRole: {
109
+ type: "integer",
110
+ description: "Candidate-pool size from 1 to 20 for each search_roles role. Defaults to 20."
111
+ },
112
+ roles: {
113
+ type: "array",
114
+ items: {
115
+ type: "object",
116
+ additionalProperties: false,
117
+ properties: {
118
+ id: {
119
+ type: "string",
120
+ required: true,
121
+ description: "Stable lowercase role id."
122
+ },
123
+ label: {
124
+ type: "string",
125
+ required: true,
126
+ description: "Short user-facing responsibility label containing 1 to 80 printable characters."
127
+ },
128
+ query: {
129
+ type: "string",
130
+ required: true,
131
+ description: "Focused capability query for this role, without unrelated responsibilities; must contain 1 to 120 printable characters."
132
+ },
133
+ required: {
134
+ type: "boolean",
135
+ description: "False only when the user explicitly made this role optional."
136
+ }
137
+ }
138
+ },
139
+ description: "For search_roles, the smallest mutually distinct coverage responsibilities needed to complete the task; keep roles distinct even if one plugin may cover several."
140
+ },
141
+ ambiguities: {
142
+ type: "array",
143
+ items: {
144
+ type: "object",
145
+ additionalProperties: false,
146
+ properties: {
147
+ id: {
148
+ type: "string",
149
+ required: true
150
+ },
151
+ question: {
152
+ type: "string",
153
+ required: true
154
+ },
155
+ options: {
156
+ type: "array",
157
+ items: { type: "string" },
158
+ required: true
159
+ }
160
+ }
161
+ },
162
+ description: "Material unresolved user choices. Any ambiguity prevents a complete assessment."
163
+ },
164
+ solutionId: {
165
+ type: "string",
166
+ description: "Short-lived id returned by search_roles."
167
+ },
168
+ selections: {
169
+ type: "array",
170
+ items: {
171
+ type: "object",
172
+ additionalProperties: false,
173
+ properties: {
174
+ roleId: {
175
+ type: "string",
176
+ required: true
177
+ },
178
+ candidateIdentities: {
179
+ type: "array",
180
+ items: { type: "string" },
181
+ required: true
182
+ }
183
+ }
184
+ },
185
+ description: "For assess_solution, reviewed candidates grouped by role; first is primary and remaining entries are materially different alternatives."
105
186
  }
106
187
  },
107
188
  output: {
@@ -734,6 +815,30 @@ function candidateSources(provider, rows) {
734
815
  }
735
816
  return output;
736
817
  }
818
+ function candidateMatchReasons(match) {
819
+ if (match?.kind === "github-owner") return [];
820
+ if (match?.kind === "exact-identifier") return [`Exact identifier: ${match.value}`];
821
+ if (match?.kind !== "registry") return [];
822
+ return [
823
+ ...match.exactIdentifier ? ["Exact Registry identifier"] : [],
824
+ ...match.canonicalPath.length === 0 ? [] : [`Semantic directory: ${match.canonicalPath.join(" / ")}`],
825
+ ...match.matchedCapabilities.length === 0 ? [] : [`Matched capabilities: ${match.matchedCapabilities.join(", ")}`]
826
+ ];
827
+ }
828
+ function matchPriority(match, exactOwner) {
829
+ if (exactOwner || match?.kind === "exact-identifier" || match?.kind === "registry" && match.exactIdentifier) return 0;
830
+ return 1;
831
+ }
832
+ function semanticMatch(match) {
833
+ if (match?.kind !== "registry" || match.canonicalPath.length === 0 && match.matchedCapabilities.length === 0) return null;
834
+ return {
835
+ directoryVersion: match.directoryVersion ?? null,
836
+ canonicalPathKey: match.canonicalPathKey ?? null,
837
+ canonicalPath: [...match.canonicalPath],
838
+ matchedCapabilities: [...match.matchedCapabilities],
839
+ retrievalSources: [...match.retrievalSources]
840
+ };
841
+ }
737
842
  async function searchPlugins(runtime, rawQuery, options = {}) {
738
843
  const parsed = parseSearchQuery(rawQuery);
739
844
  const maxResults = Math.max(1, Math.min(20, options.maxResults ?? 20));
@@ -770,17 +875,44 @@ async function searchPlugins(runtime, rawQuery, options = {}) {
770
875
  return { ok: false };
771
876
  }
772
877
  }));
773
- const projects = /* @__PURE__ */ new Map();
774
- let rejectedCandidates = 0;
775
- for (const result of inspected) {
776
- if (!result.ok) {
777
- rejectedCandidates += 1;
778
- continue;
878
+ const accepted = inspected.flatMap((result) => result.ok ? [result] : []);
879
+ const parent = accepted.map((_, index) => index);
880
+ const root = (index) => {
881
+ let current = index;
882
+ while (parent[current] !== current) current = parent[current];
883
+ while (parent[index] !== index) {
884
+ const next = parent[index];
885
+ parent[index] = current;
886
+ index = next;
887
+ }
888
+ return current;
889
+ };
890
+ const join = (left, right) => {
891
+ const leftRoot = root(left);
892
+ const rightRoot = root(right);
893
+ if (leftRoot === rightRoot) return;
894
+ parent[Math.max(leftRoot, rightRoot)] = Math.min(leftRoot, rightRoot);
895
+ };
896
+ const aliasOwner = /* @__PURE__ */ new Map();
897
+ for (const [index, result] of accepted.entries()) {
898
+ const aliases = [`package:${result.inspection.packageName.toLowerCase()}`, ...result.inspection.repository === null ? [] : [`repository:${result.inspection.repository.toLowerCase()}`]];
899
+ for (const alias of aliases) {
900
+ const owner = aliasOwner.get(alias);
901
+ if (owner === void 0) aliasOwner.set(alias, index);
902
+ else join(index, owner);
779
903
  }
904
+ }
905
+ const projects = /* @__PURE__ */ new Map();
906
+ const rejectedCandidates = inspected.length - accepted.length;
907
+ for (const [acceptedIndex, result] of accepted.entries()) {
908
+ const project = root(acceptedIndex);
780
909
  const identity = inspectionIdentity(result.inspection);
781
910
  const repositoryOwner = /^github\.com\/([^/]+)\//iu.exec(result.inspection.repository ?? "")?.[1]?.toLowerCase() ?? null;
782
- const exactOwner = result.item.match?.kind === "github-owner" && repositoryOwner === result.item.match.value.toLowerCase();
783
- const existing = projects.get(identity) ?? {
911
+ const exactOwnerValue = result.item.match?.kind === "github-owner" ? result.item.match.value : null;
912
+ const exactOwner = exactOwnerValue !== null && repositoryOwner === exactOwnerValue.toLowerCase();
913
+ const reasons = candidateMatchReasons(result.item.match);
914
+ const semantic = semanticMatch(result.item.match);
915
+ const existing = projects.get(project) ?? {
784
916
  identity,
785
917
  packageName: result.inspection.packageName,
786
918
  description: result.inspection.description,
@@ -788,16 +920,19 @@ async function searchPlugins(runtime, rawQuery, options = {}) {
788
920
  repositoryOwner,
789
921
  providers: [],
790
922
  matchReasons: [],
923
+ semanticMatches: [],
791
924
  sources: [],
792
925
  recommendedSource: result.inspection.installSpec,
793
926
  rank: result.item.rank,
794
- matchPriority: exactOwner ? 0 : 1
927
+ matchPriority: matchPriority(result.item.match, exactOwner)
795
928
  };
796
929
  if (!existing.providers.includes(result.item.provider)) existing.providers.push(result.item.provider);
797
930
  if (exactOwner) {
798
- const reason = `Exact GitHub owner: ${result.item.match.value}`;
931
+ const reason = `Exact GitHub owner: ${exactOwnerValue}`;
799
932
  if (!existing.matchReasons.includes(reason)) existing.matchReasons.push(reason);
800
933
  }
934
+ for (const reason of reasons) if (!existing.matchReasons.includes(reason)) existing.matchReasons.push(reason);
935
+ if (semantic !== null && !existing.semanticMatches.some((item) => item.directoryVersion === semantic.directoryVersion && item.canonicalPathKey === semantic.canonicalPathKey)) existing.semanticMatches.push(semantic);
801
936
  const sameSource = existing.sources.find((source) => source.inspection.installSpec === result.inspection.installSpec);
802
937
  if (sameSource === void 0) existing.sources.push({
803
938
  inspection: result.inspection,
@@ -809,9 +944,9 @@ async function searchPlugins(runtime, rawQuery, options = {}) {
809
944
  for (const evidence of result.item.evidence) if (!sameSource.evidence.includes(evidence)) sameSource.evidence.push(evidence);
810
945
  }
811
946
  existing.rank = Math.min(existing.rank, result.item.rank);
812
- existing.matchPriority = Math.min(existing.matchPriority, exactOwner ? 0 : 1);
947
+ existing.matchPriority = Math.min(existing.matchPriority, matchPriority(result.item.match, exactOwner));
813
948
  existing.recommendedSource = existing.sources.find((source) => source.inspection.sourceType === "npm")?.inspection.installSpec ?? existing.sources[0].inspection.installSpec;
814
- projects.set(identity, existing);
949
+ projects.set(project, existing);
815
950
  }
816
951
  const candidates = [...projects.values()].sort((left, right) => left.matchPriority - right.matchPriority || left.rank - right.rank || left.packageName.localeCompare(right.packageName)).slice(0, maxResults).map(({ rank: _providerRank, matchPriority: _matchPriority, ...candidate }, index) => ({
817
952
  ...candidate,
@@ -825,8 +960,10 @@ async function searchPlugins(runtime, rawQuery, options = {}) {
825
960
  order: "rank_ascending",
826
961
  returnedCandidates: candidates.length,
827
962
  requestedMaximum: maxResults,
828
- includeEveryPossiblyRelevant: true,
963
+ includeEveryDistinctRelevantSolution: true,
829
964
  excludeClearlyIrrelevant: true,
965
+ deduplicateEquivalentSources: true,
966
+ padToRequestedMaximum: false,
830
967
  silentTopNTruncation: false
831
968
  },
832
969
  providerErrors,
@@ -1205,6 +1342,177 @@ var OperationTracker = class {
1205
1342
  }
1206
1343
  };
1207
1344
  //#endregion
1345
+ //#region src/task-solutions.ts
1346
+ const DEFAULT_TTL_MS = 600 * 1e3;
1347
+ const MAX_ROLES = 8;
1348
+ const MAX_AMBIGUITIES = 4;
1349
+ const MAX_OPTIONS = 8;
1350
+ const MAX_DRAFTS = 32;
1351
+ const ROLE_ID = /^[a-z][a-z0-9_]{0,63}$/u;
1352
+ function bounded(value, name, maximum) {
1353
+ const text = typeof value === "string" ? value.trim() : "";
1354
+ if (text === "" || text.length > maximum || /[\u0000-\u001f\u007f]/u.test(text)) fail("INVALID_TASK_SOLUTION", `${name} must contain 1 to ${String(maximum)} printable characters.`);
1355
+ return text;
1356
+ }
1357
+ function validateTaskSolutionPlan(taskValue, roleValues, ambiguityValues = []) {
1358
+ const task = bounded(taskValue, "Task", 1e3);
1359
+ if (!Array.isArray(roleValues) || roleValues.length < 1 || roleValues.length > MAX_ROLES) fail("INVALID_TASK_SOLUTION", `A task solution requires 1 to ${String(MAX_ROLES)} roles.`);
1360
+ const roleIds = /* @__PURE__ */ new Set();
1361
+ const roleLabels = /* @__PURE__ */ new Set();
1362
+ const roleQueries = /* @__PURE__ */ new Set();
1363
+ const roles = roleValues.map((value) => {
1364
+ const id = typeof value?.id === "string" ? value.id.trim() : "";
1365
+ if (!ROLE_ID.test(id)) fail("INVALID_TASK_SOLUTION", "Role ids must be lowercase stable identifiers.");
1366
+ if (roleIds.has(id)) fail("INVALID_TASK_SOLUTION", "Role ids must be unique.");
1367
+ roleIds.add(id);
1368
+ const label = bounded(value.label, "Role label", 80);
1369
+ const query = bounded(value.query, "Role query", 120);
1370
+ const labelKey = label.toLocaleLowerCase();
1371
+ const queryKey = query.toLocaleLowerCase().replace(/\s+/gu, " ");
1372
+ if (roleLabels.has(labelKey) || roleQueries.has(queryKey)) fail("INVALID_TASK_SOLUTION", "Role labels and focused queries must be unique.");
1373
+ roleLabels.add(labelKey);
1374
+ roleQueries.add(queryKey);
1375
+ return {
1376
+ id,
1377
+ label,
1378
+ query,
1379
+ required: value.required !== false
1380
+ };
1381
+ });
1382
+ if (!Array.isArray(ambiguityValues) || ambiguityValues.length > MAX_AMBIGUITIES) fail("INVALID_TASK_SOLUTION", `A task solution supports at most ${String(MAX_AMBIGUITIES)} unresolved ambiguities.`);
1383
+ const ambiguityIds = /* @__PURE__ */ new Set();
1384
+ return {
1385
+ task,
1386
+ roles,
1387
+ ambiguities: ambiguityValues.map((value) => {
1388
+ const id = typeof value?.id === "string" ? value.id.trim() : "";
1389
+ if (!ROLE_ID.test(id) || ambiguityIds.has(id) || roleIds.has(id)) fail("INVALID_TASK_SOLUTION", "Ambiguity ids must be unique lowercase stable identifiers and cannot reuse a role id.");
1390
+ ambiguityIds.add(id);
1391
+ if (!Array.isArray(value.options) || value.options.length < 2 || value.options.length > MAX_OPTIONS) fail("INVALID_TASK_SOLUTION", `Each ambiguity requires 2 to ${String(MAX_OPTIONS)} options.`);
1392
+ const options = value.options.map((option) => bounded(option, "Ambiguity option", 80));
1393
+ if (new Set(options).size !== options.length) fail("INVALID_TASK_SOLUTION", "Ambiguity options must be unique.");
1394
+ return {
1395
+ id,
1396
+ question: bounded(value.question, "Ambiguity question", 200),
1397
+ options
1398
+ };
1399
+ })
1400
+ };
1401
+ }
1402
+ function candidateSummary(candidate) {
1403
+ const { sources: _sources, ...summary } = candidate;
1404
+ return structuredClone(summary);
1405
+ }
1406
+ var TaskSolutionStore = class {
1407
+ now;
1408
+ id;
1409
+ ttlMs;
1410
+ drafts = /* @__PURE__ */ new Map();
1411
+ constructor(options = {}) {
1412
+ this.now = options.now ?? Date.now;
1413
+ this.id = options.id ?? (() => `task-solution:${randomUUID()}`);
1414
+ this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
1415
+ if (!Number.isSafeInteger(this.ttlMs) || this.ttlMs < 1e3 || this.ttlMs > 3600 * 1e3) throw new RangeError("Task solution ttlMs must be from 1 second to 1 hour.");
1416
+ }
1417
+ create(input) {
1418
+ const plan = validateTaskSolutionPlan(input.task, input.roles, input.ambiguities);
1419
+ const now = this.now();
1420
+ for (const [id, draft] of this.drafts) if (draft.expiresAt <= now) this.drafts.delete(id);
1421
+ while (this.drafts.size >= MAX_DRAFTS) this.drafts.delete(this.drafts.keys().next().value);
1422
+ const roles = plan.roles.map((role) => {
1423
+ const search = input.searches[role.id];
1424
+ if (search === void 0) fail("INVALID_TASK_SOLUTION", `Search results are missing for role ${role.id}.`);
1425
+ return {
1426
+ ...role,
1427
+ candidates: search.candidates.map(candidateSummary),
1428
+ providerErrors: structuredClone(search.providerErrors),
1429
+ rejectedCandidates: search.rejectedCandidates
1430
+ };
1431
+ });
1432
+ const requiredCandidateMissing = roles.some((role) => role.required && role.candidates.length === 0);
1433
+ const draft = {
1434
+ schemaVersion: "1.0.0",
1435
+ solutionId: bounded(this.id(), "Task solution id", 200),
1436
+ task: plan.task,
1437
+ status: plan.ambiguities.length > 0 ? "ambiguous" : requiredCandidateMissing ? "incomplete" : "needs_review",
1438
+ createdAt: now,
1439
+ expiresAt: now + this.ttlMs,
1440
+ roles,
1441
+ ambiguities: plan.ambiguities
1442
+ };
1443
+ if (this.drafts.has(draft.solutionId)) fail("INVALID_TASK_SOLUTION", "Task solution id must be unique.");
1444
+ this.drafts.set(draft.solutionId, draft);
1445
+ return structuredClone(draft);
1446
+ }
1447
+ assess(solutionIdValue, selectionValues) {
1448
+ const solutionId = bounded(solutionIdValue, "Task solution id", 200);
1449
+ const draft = this.drafts.get(solutionId);
1450
+ if (draft === void 0) fail("TASK_SOLUTION_NOT_FOUND", "Task solution draft was not found.");
1451
+ if (draft.expiresAt <= this.now()) {
1452
+ this.drafts.delete(solutionId);
1453
+ fail("TASK_SOLUTION_EXPIRED", "Task solution draft has expired; search the roles again.");
1454
+ }
1455
+ if (!Array.isArray(selectionValues) || selectionValues.length > draft.roles.length) fail("INVALID_TASK_SOLUTION", "Task solution selections must contain at most one row per role.");
1456
+ const roleById = new Map(draft.roles.map((role) => [role.id, role]));
1457
+ const selections = /* @__PURE__ */ new Map();
1458
+ for (const value of selectionValues) {
1459
+ const roleId = typeof value?.roleId === "string" ? value.roleId.trim() : "";
1460
+ const role = roleById.get(roleId);
1461
+ if (role === void 0) fail("INVALID_TASK_SOLUTION", `Unknown task solution role ${roleId}.`);
1462
+ if (selections.has(roleId)) fail("INVALID_TASK_SOLUTION", `Role ${roleId} has duplicate selection rows.`);
1463
+ if (!Array.isArray(value.candidateIdentities) || value.candidateIdentities.length > 20) fail("INVALID_TASK_SOLUTION", `Role ${roleId} has too many candidate identities.`);
1464
+ const identities = value.candidateIdentities.map((identity) => bounded(identity, "Candidate identity", 500));
1465
+ if (new Set(identities).size !== identities.length) fail("INVALID_TASK_SOLUTION", `Role ${roleId} contains a duplicate candidate.`);
1466
+ const available = new Set(role.candidates.map((candidate) => candidate.identity));
1467
+ for (const identity of identities) if (!available.has(identity)) fail("INVALID_TASK_SOLUTION", `${identity} is not a candidate for role ${roleId}.`);
1468
+ selections.set(roleId, identities);
1469
+ }
1470
+ const solutions = /* @__PURE__ */ new Map();
1471
+ const roles = draft.roles.map((role) => {
1472
+ const identities = selections.get(role.id) ?? [];
1473
+ for (const identity of identities) {
1474
+ const candidate = role.candidates.find((item) => item.identity === identity);
1475
+ const existing = solutions.get(identity);
1476
+ if (existing === void 0) solutions.set(identity, {
1477
+ ...structuredClone(candidate),
1478
+ roleIds: [role.id]
1479
+ });
1480
+ else if (!existing.roleIds.includes(role.id)) existing.roleIds.push(role.id);
1481
+ }
1482
+ return {
1483
+ id: role.id,
1484
+ label: role.label,
1485
+ query: role.query,
1486
+ required: role.required,
1487
+ status: identities.length > 0 ? "covered" : role.required ? "missing_required" : "missing_optional",
1488
+ primaryCandidateIdentity: identities[0] ?? null,
1489
+ alternativeCandidateIdentities: identities.slice(1)
1490
+ };
1491
+ });
1492
+ const required = roles.filter((role) => role.required);
1493
+ const optional = roles.filter((role) => !role.required);
1494
+ const missingRequiredRoleIds = required.filter((role) => role.status !== "covered").map((role) => role.id);
1495
+ const complete = missingRequiredRoleIds.length === 0 && draft.ambiguities.length === 0;
1496
+ return {
1497
+ schemaVersion: "1.0.0",
1498
+ solutionId,
1499
+ task: draft.task,
1500
+ status: draft.ambiguities.length > 0 ? "ambiguous" : complete ? "complete" : "incomplete",
1501
+ roles,
1502
+ ambiguities: structuredClone(draft.ambiguities),
1503
+ solutions: [...solutions.values()],
1504
+ coverage: {
1505
+ requiredRoles: required.length,
1506
+ coveredRequiredRoles: required.filter((role) => role.status === "covered").length,
1507
+ optionalRoles: optional.length,
1508
+ coveredOptionalRoles: optional.filter((role) => role.status === "covered").length,
1509
+ missingRequiredRoleIds,
1510
+ complete
1511
+ }
1512
+ };
1513
+ }
1514
+ };
1515
+ //#endregion
1208
1516
  //#region src/manager.ts
1209
1517
  const MAX_INSTALL_MANY_SOURCES = 20;
1210
1518
  const FIBER_PHASE = {
@@ -1258,7 +1566,10 @@ const TELEMETRY_ERROR_CODES = /* @__PURE__ */ new Set([
1258
1566
  "DSH_COMMAND_FAILED",
1259
1567
  "BATCH_INSTALL_FAILED",
1260
1568
  "POSTCONDITION_FAILED",
1261
- "RESTART_UNAVAILABLE"
1569
+ "RESTART_UNAVAILABLE",
1570
+ "INVALID_TASK_SOLUTION",
1571
+ "TASK_SOLUTION_NOT_FOUND",
1572
+ "TASK_SOLUTION_EXPIRED"
1262
1573
  ]);
1263
1574
  function safePackageName(value) {
1264
1575
  const name = value?.trim() ?? "";
@@ -1315,6 +1626,7 @@ var PluginManager = class {
1315
1626
  fetchOptions;
1316
1627
  hmrTimeoutMs;
1317
1628
  telemetry;
1629
+ taskSolutions;
1318
1630
  constructor(dependencies) {
1319
1631
  this.profileDir = dependencies.profileDir;
1320
1632
  this.searchRuntime = dependencies.searchRuntime;
@@ -1328,6 +1640,7 @@ var PluginManager = class {
1328
1640
  this.fetchOptions = dependencies.fetchOptions ?? {};
1329
1641
  this.hmrTimeoutMs = dependencies.hmrTimeoutMs ?? 5e3;
1330
1642
  this.telemetry = dependencies.telemetry ?? { capture() {} };
1643
+ this.taskSolutions = dependencies.taskSolutions ?? new TaskSolutionStore();
1331
1644
  }
1332
1645
  capture(event, properties = {}) {
1333
1646
  try {
@@ -1356,7 +1669,7 @@ var PluginManager = class {
1356
1669
  this.capture("plugin_manager_used", {
1357
1670
  surface: "discover",
1358
1671
  action: request.action,
1359
- ...request.action === "search" ? {
1672
+ ...request.action === "search" || request.action === "search_roles" ? {
1360
1673
  has_query: (request.query?.trim().length ?? 0) > 0,
1361
1674
  query_length_bucket: queryLengthBucket(request.query)
1362
1675
  } : {}
@@ -1374,6 +1687,27 @@ var PluginManager = class {
1374
1687
  };
1375
1688
  return await searchPlugins(this.searchRuntime, request.query ?? "", options);
1376
1689
  }
1690
+ if (request.action === "search_roles") {
1691
+ const plan = validateTaskSolutionPlan(request.query ?? "", request.roles ?? [], request.ambiguities ?? []);
1692
+ const maxResultsPerRole = request.maxResultsPerRole ?? 20;
1693
+ if (!Number.isInteger(maxResultsPerRole) || maxResultsPerRole < 1 || maxResultsPerRole > 20) fail("INVALID_TASK_SOLUTION", "maxResultsPerRole must be an integer from 1 to 20.");
1694
+ const searches = await Promise.all(plan.roles.map(async (role) => [role.id, await searchPlugins(this.searchRuntime, role.query, {
1695
+ ...this.fetchOptions,
1696
+ signal,
1697
+ maxResults: maxResultsPerRole,
1698
+ inspect: this.inspect
1699
+ })]));
1700
+ return this.taskSolutions.create({
1701
+ task: plan.task,
1702
+ roles: plan.roles,
1703
+ ambiguities: plan.ambiguities,
1704
+ searches: Object.fromEntries(searches)
1705
+ });
1706
+ }
1707
+ if (request.action === "assess_solution") {
1708
+ if (request.solutionId === void 0) fail("INVALID_TASK_SOLUTION", "A task solution id is required for assessment.");
1709
+ return this.taskSolutions.assess(request.solutionId, request.selections ?? []);
1710
+ }
1377
1711
  if (request.action === "inspect") {
1378
1712
  if (request.target === void 0) fail("INVALID_SOURCE", "A plugin source is required for inspection.");
1379
1713
  return await this.inspect(request.target, {
@@ -1795,7 +2129,14 @@ var PluginManager = class {
1795
2129
  //#endregion
1796
2130
  //#region src/providers.ts
1797
2131
  const MAX_PROVIDER_RESULTS = 20;
2132
+ const REGISTRY_KEYWORD_CHALLENGER_POOL = 21;
2133
+ const REGISTRY_DIRECTORY_POOL = 50;
2134
+ const RECIPROCAL_RANK_OFFSET = 20;
2135
+ const KEYWORD_RANK_WEIGHT = .1;
2136
+ const DIRECTORY_RANK_WEIGHT = .2;
2137
+ const IDENTITY_TERM_BOOST = .01;
1798
2138
  const REGISTRY_SNAPSHOT_ID = /^discovery\.[a-z0-9.-]+$/u;
2139
+ const REGISTRY_DIRECTORY_VERSION = /^[a-z0-9][a-z0-9._-]{0,127}$/u;
1799
2140
  function query(value) {
1800
2141
  const normalized = value.trim();
1801
2142
  if (normalized === "" || normalized.length > 120 || /[\u0000-\u001f\u007f]/u.test(normalized)) throw new Error("Search query must contain 1 to 120 printable characters.");
@@ -1836,7 +2177,11 @@ function npmSearchProvider(fetchImpl = globalThis.fetch) {
1836
2177
  package: text
1837
2178
  }],
1838
2179
  score: Number.MAX_SAFE_INTEGER,
1839
- evidence: ["Exact npm package-name query"]
2180
+ evidence: ["Exact npm package-name query"],
2181
+ match: {
2182
+ kind: "exact-identifier",
2183
+ value: text
2184
+ }
1840
2185
  }, ...searched];
1841
2186
  }
1842
2187
  };
@@ -1911,7 +2256,7 @@ function githubSearchProvider(fetchImpl = globalThis.fetch, env = process.env) {
1911
2256
  }
1912
2257
  };
1913
2258
  }
1914
- function registryEndpoint(value) {
2259
+ function registryEndpoint(value, operation) {
1915
2260
  let url;
1916
2261
  try {
1917
2262
  url = new URL(value);
@@ -1921,7 +2266,7 @@ function registryEndpoint(value) {
1921
2266
  const local = url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "::1";
1922
2267
  if (url.protocol !== "https:" && !(local && url.protocol === "http:")) throw new Error("Registry URL must use HTTPS, except for an explicit local development endpoint.");
1923
2268
  if (url.username !== "" || url.password !== "" || url.search !== "" || url.hash !== "") throw new Error("Registry URL cannot contain credentials, query parameters, or a fragment.");
1924
- url.pathname = `${url.pathname.replace(/\/$/u, "")}/v1/plugins:search`;
2269
+ url.pathname = `${url.pathname.replace(/\/$/u, "")}/v1/plugins:${operation}`;
1925
2270
  return url.href;
1926
2271
  }
1927
2272
  function boundedText(value, maximum = 4e3) {
@@ -1930,7 +2275,19 @@ function boundedText(value, maximum = 4e3) {
1930
2275
  function queryLocale(value) {
1931
2276
  return /\p{Script=Han}/u.test(value) ? "zh-CN" : "en";
1932
2277
  }
1933
- function registryCandidate(value, snapshotId) {
2278
+ function safeCodes(value, maximum = 20) {
2279
+ return Array.isArray(value) ? value.filter((item) => typeof item === "string" && /^[a-z0-9._-]+$/u.test(item)).slice(0, maximum) : [];
2280
+ }
2281
+ function safePath(value, locale) {
2282
+ if (!Array.isArray(value)) return [];
2283
+ return value.flatMap((item) => {
2284
+ if (typeof item !== "object" || item === null || Array.isArray(item)) return [];
2285
+ const path = item;
2286
+ const label = locale === "en" ? boundedText(path.en, 200) ?? boundedText(path.zh_CN, 200) : boundedText(path.zh_CN, 200) ?? boundedText(path.en, 200);
2287
+ return label === void 0 ? [] : [label];
2288
+ }).slice(0, 8);
2289
+ }
2290
+ function registryCandidate(value, metadata) {
1934
2291
  if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
1935
2292
  const candidate = value;
1936
2293
  const entry = candidate.entry;
@@ -1961,7 +2318,12 @@ function registryCandidate(value, snapshotId) {
1961
2318
  const zh = boundedText(entry.imported_content?.description?.["zh-CN"]);
1962
2319
  const en = boundedText(entry.imported_content?.description?.en);
1963
2320
  const repository = boundedText(entry.identity?.repository_url, 500);
1964
- const reasonCodes = Array.isArray(candidate.match?.reason_codes) ? candidate.match.reason_codes.filter((item) => typeof item === "string" && /^[a-z0-9_]+$/u.test(item)).slice(0, 8) : [];
2321
+ const reasonCodes = safeCodes(candidate.match?.reason_codes ?? candidate.match?.keyword_reason_codes, 8);
2322
+ const retrievalSources = safeCodes(candidate.match?.retrieval_sources, 8);
2323
+ const canonicalPathKey = boundedText(candidate.match?.canonical_path_key, 500);
2324
+ const canonicalPath = safePath(candidate.match?.canonical_primary_path, metadata.locale);
2325
+ const matchedCapabilities = safeCodes(candidate.match?.matched_capabilities, 8);
2326
+ const exactIdentifier = reasonCodes.includes("exact_identifier");
1965
2327
  const score = typeof candidate.match?.score === "number" && Number.isFinite(candidate.match.score) && candidate.match.score >= 0 ? candidate.match.score : void 0;
1966
2328
  return {
1967
2329
  id: `registry:${entry.entry_id}`,
@@ -1974,19 +2336,123 @@ function registryCandidate(value, snapshotId) {
1974
2336
  sources,
1975
2337
  ...score === void 0 ? {} : { score },
1976
2338
  evidence: [
1977
- `DSH Registry source snapshot: ${snapshotId}`,
2339
+ `DSH Registry source snapshot: ${metadata.snapshotId}`,
2340
+ ...metadata.directoryVersion === void 0 ? [] : [`DSH Registry directory version: ${metadata.directoryVersion}`],
2341
+ ...canonicalPath.length === 0 ? [] : [`Semantic directory: ${canonicalPath.join(" / ")}`],
2342
+ ...matchedCapabilities.length === 0 ? [] : [`Matched capabilities: ${matchedCapabilities.join(", ")}`],
1978
2343
  "Registry discovery record only; compatibility and security not tested",
1979
2344
  ...reasonCodes.map((code) => `Registry match: ${code}`)
1980
- ]
2345
+ ],
2346
+ match: {
2347
+ kind: "registry",
2348
+ strategy: metadata.strategy,
2349
+ snapshotId: metadata.snapshotId,
2350
+ ...metadata.directoryVersion === void 0 ? {} : { directoryVersion: metadata.directoryVersion },
2351
+ retrievalSources,
2352
+ keywordReasonCodes: reasonCodes,
2353
+ ...canonicalPathKey === void 0 ? {} : { canonicalPathKey },
2354
+ canonicalPath,
2355
+ matchedCapabilities,
2356
+ exactIdentifier
2357
+ }
2358
+ };
2359
+ }
2360
+ async function parseRegistryResponse(response, strategy, locale) {
2361
+ if (!response.ok) throw new Error(`DSH Registry ${strategy === "keyword" ? "search" : "directory route"} returned HTTP ${response.status}`);
2362
+ const data = await response.json();
2363
+ const directoryVersion = strategy === "keyword-plus-semantic-directory-v1" && typeof data.directory_version === "string" && REGISTRY_DIRECTORY_VERSION.test(data.directory_version) ? data.directory_version : void 0;
2364
+ if (typeof data.snapshot_id !== "string" || !REGISTRY_SNAPSHOT_ID.test(data.snapshot_id) || !Array.isArray(data.candidates) || data.is_final_recommendation === true || data.grants_install_approval === true || strategy === "keyword-plus-semantic-directory-v1" && directoryVersion === void 0) throw new Error("DSH Registry search returned an invalid discovery response.");
2365
+ const metadata = {
2366
+ snapshotId: data.snapshot_id,
2367
+ strategy,
2368
+ locale,
2369
+ ...directoryVersion === void 0 ? {} : { directoryVersion }
2370
+ };
2371
+ return {
2372
+ metadata,
2373
+ candidates: data.candidates.flatMap((candidate) => {
2374
+ const normalized = registryCandidate(candidate, metadata);
2375
+ return normalized === null ? [] : [normalized];
2376
+ })
1981
2377
  };
1982
2378
  }
1983
- function registrySearchProvider(baseUrl, fetchImpl = globalThis.fetch) {
1984
- const endpoint = registryEndpoint(baseUrl);
2379
+ const IDENTITY_STOP_TERMS = /* @__PURE__ */ new Set([
2380
+ "and",
2381
+ "dsh",
2382
+ "for",
2383
+ "from",
2384
+ "inside",
2385
+ "into",
2386
+ "plugin",
2387
+ "plugins",
2388
+ "the",
2389
+ "use",
2390
+ "using",
2391
+ "with"
2392
+ ]);
2393
+ function identityTermCoverage(searchText, candidate) {
2394
+ const terms = [...new Set(searchText.toLowerCase().match(/[a-z0-9@]+/gu) ?? [])].filter((term) => term.length >= 3 && !IDENTITY_STOP_TERMS.has(term));
2395
+ if (terms.length === 0) return 0;
2396
+ const identityTerms = new Set(`${candidate.title} ${candidate.repository ?? ""}`.toLowerCase().split(/[^a-z0-9@]+/gu).filter(Boolean));
2397
+ return terms.filter((term) => identityTerms.has(term)).length / terms.length;
2398
+ }
2399
+ function mergeRegistryRankings(searchText, keyword, directory, limit) {
2400
+ const combined = /* @__PURE__ */ new Map();
2401
+ for (const [source, response, weight] of [[
2402
+ "keyword",
2403
+ keyword,
2404
+ KEYWORD_RANK_WEIGHT
2405
+ ], [
2406
+ "directory",
2407
+ directory,
2408
+ DIRECTORY_RANK_WEIGHT
2409
+ ]]) {
2410
+ if (response === null) continue;
2411
+ response.candidates.forEach((candidate, index) => {
2412
+ if (source === "directory" && keyword !== null && !combined.has(candidate.id)) return;
2413
+ const current = combined.get(candidate.id) ?? {
2414
+ candidate,
2415
+ score: 0
2416
+ };
2417
+ current.score += weight / (RECIPROCAL_RANK_OFFSET + index + 1);
2418
+ if (source === "keyword") current.keywordRank = index + 1;
2419
+ else {
2420
+ current.directoryRank = index + 1;
2421
+ current.candidate = candidate;
2422
+ }
2423
+ combined.set(candidate.id, current);
2424
+ });
2425
+ }
2426
+ return [...combined.values()].map((item) => {
2427
+ const keywordMatch = keyword?.candidates.find((candidate) => candidate.id === item.candidate.id)?.match;
2428
+ const directoryMatch = directory?.candidates.find((candidate) => candidate.id === item.candidate.id)?.match;
2429
+ const exactIdentifier = keywordMatch?.kind === "registry" && keywordMatch.exactIdentifier || directoryMatch?.kind === "registry" && directoryMatch.exactIdentifier;
2430
+ const registryMatch = directoryMatch?.kind === "registry" ? directoryMatch : keywordMatch?.kind === "registry" ? keywordMatch : null;
2431
+ const score = item.score + IDENTITY_TERM_BOOST * identityTermCoverage(searchText, item.candidate) + (exactIdentifier ? 1 : 0);
2432
+ return {
2433
+ ...item.candidate,
2434
+ score,
2435
+ ...registryMatch === null ? {} : { match: {
2436
+ ...registryMatch,
2437
+ strategy: directory === null ? "keyword" : "keyword-plus-semantic-directory-v1",
2438
+ keywordReasonCodes: keywordMatch?.kind === "registry" ? keywordMatch.keywordReasonCodes : registryMatch.keywordReasonCodes,
2439
+ exactIdentifier
2440
+ } },
2441
+ evidence: [...item.candidate.evidence ?? [], `Registry rank fusion: keyword=${String(item.keywordRank ?? "none")}, directory=${String(item.directoryRank ?? "none")}`]
2442
+ };
2443
+ }).sort((left, right) => (right.score ?? 0) - (left.score ?? 0) || left.id.localeCompare(right.id)).slice(0, limit);
2444
+ }
2445
+ function registrySearchProvider(baseUrl, fetchImpl = globalThis.fetch, options = {}) {
2446
+ const keywordEndpoint = registryEndpoint(baseUrl, "search");
2447
+ const directoryEndpoint = registryEndpoint(baseUrl, "route");
2448
+ const strategy = options.strategy ?? "hybrid";
1985
2449
  return {
1986
2450
  id: "dsh-registry",
1987
2451
  async search(request) {
1988
2452
  const text = query(request.query);
1989
- const response = await fetchImpl(endpoint, {
2453
+ const locale = queryLocale(text);
2454
+ const outputLimit = Math.min(request.maxResults, MAX_PROVIDER_RESULTS);
2455
+ const requestEndpoint = async (endpoint, responseStrategy, limit) => parseRegistryResponse(await fetchImpl(endpoint, {
1990
2456
  method: "POST",
1991
2457
  signal: request.signal,
1992
2458
  headers: {
@@ -1996,17 +2462,20 @@ function registrySearchProvider(baseUrl, fetchImpl = globalThis.fetch) {
1996
2462
  body: JSON.stringify({
1997
2463
  schema_version: "1.0.0",
1998
2464
  query: text,
1999
- locale: queryLocale(text),
2000
- limit: Math.min(request.maxResults, MAX_PROVIDER_RESULTS)
2465
+ locale,
2466
+ limit
2001
2467
  })
2002
- });
2003
- if (!response.ok) throw new Error(`DSH Registry search returned HTTP ${response.status}`);
2004
- const data = await response.json();
2005
- if (typeof data.snapshot_id !== "string" || !REGISTRY_SNAPSHOT_ID.test(data.snapshot_id) || !Array.isArray(data.candidates)) throw new Error("DSH Registry search returned an invalid discovery response.");
2006
- return data.candidates.flatMap((candidate) => {
2007
- const normalized = registryCandidate(candidate, data.snapshot_id);
2008
- return normalized === null ? [] : [normalized];
2009
- }).slice(0, Math.min(request.maxResults, MAX_PROVIDER_RESULTS));
2468
+ }), responseStrategy, locale);
2469
+ if (strategy === "keyword") return (await requestEndpoint(keywordEndpoint, "keyword", outputLimit)).candidates.slice(0, outputLimit);
2470
+ const [keywordResult, directoryResult] = await Promise.allSettled([requestEndpoint(keywordEndpoint, "keyword", REGISTRY_KEYWORD_CHALLENGER_POOL), requestEndpoint(directoryEndpoint, "keyword-plus-semantic-directory-v1", REGISTRY_DIRECTORY_POOL)]);
2471
+ const keyword = keywordResult.status === "fulfilled" ? keywordResult.value : null;
2472
+ const directory = directoryResult.status === "fulfilled" ? directoryResult.value : null;
2473
+ if (keyword === null && directory === null) {
2474
+ const reasons = [keywordResult, directoryResult].map((result) => result.status === "rejected" ? result.reason instanceof Error ? result.reason.message : String(result.reason) : "").filter(Boolean);
2475
+ throw new Error(`DSH Registry search failed: ${reasons.join("; ")}`);
2476
+ }
2477
+ if (keyword !== null && directory !== null && keyword.metadata.snapshotId !== directory.metadata.snapshotId) throw new Error("DSH Registry keyword and directory responses reference different snapshots.");
2478
+ return mergeRegistryRankings(text, keyword, directory, outputLimit);
2010
2479
  }
2011
2480
  };
2012
2481
  }