@withpica/mcp-server 2.95.3 → 2.96.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.
Files changed (206) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.intro.md +115 -0
  3. package/README.md +188 -204
  4. package/dist/lib/changelog.generated.d.ts +2 -2
  5. package/dist/lib/changelog.generated.d.ts.map +1 -1
  6. package/dist/lib/changelog.generated.js +2 -2
  7. package/dist/lib/changelog.generated.js.map +1 -1
  8. package/dist/prompts/creator-question-atlas.d.ts.map +1 -1
  9. package/dist/prompts/creator-question-atlas.js +11 -0
  10. package/dist/prompts/creator-question-atlas.js.map +1 -1
  11. package/dist/prompts/index.js +2 -2
  12. package/dist/resources/agent-guide.d.ts +50 -4
  13. package/dist/resources/agent-guide.d.ts.map +1 -1
  14. package/dist/resources/agent-guide.js +109 -18
  15. package/dist/resources/agent-guide.js.map +1 -1
  16. package/dist/resources/index.d.ts.map +1 -1
  17. package/dist/resources/index.js +81 -2
  18. package/dist/resources/index.js.map +1 -1
  19. package/dist/resources/llms-primer.d.ts +1 -1
  20. package/dist/resources/llms-primer.d.ts.map +1 -1
  21. package/dist/resources/llms-primer.js +1 -1
  22. package/dist/resources/required-schemas.generated.d.ts +3 -12
  23. package/dist/resources/required-schemas.generated.d.ts.map +1 -1
  24. package/dist/resources/required-schemas.generated.js +3 -18
  25. package/dist/resources/required-schemas.generated.js.map +1 -1
  26. package/dist/resources/required-schemas.source.d.ts.map +1 -1
  27. package/dist/resources/required-schemas.source.js +13 -15
  28. package/dist/resources/required-schemas.source.js.map +1 -1
  29. package/dist/server-instructions.d.ts +2 -2
  30. package/dist/server-instructions.d.ts.map +1 -1
  31. package/dist/server-instructions.js +3 -3
  32. package/dist/skills/skills.generated.d.ts.map +1 -1
  33. package/dist/skills/skills.generated.js +18 -9
  34. package/dist/skills/skills.generated.js.map +1 -1
  35. package/dist/tools/agent-identity.d.ts.map +1 -1
  36. package/dist/tools/agent-identity.js +2 -1
  37. package/dist/tools/agent-identity.js.map +1 -1
  38. package/dist/tools/agreement-types.d.ts.map +1 -1
  39. package/dist/tools/agreement-types.js +23 -11
  40. package/dist/tools/agreement-types.js.map +1 -1
  41. package/dist/tools/agreements.d.ts.map +1 -1
  42. package/dist/tools/agreements.js +17 -9
  43. package/dist/tools/agreements.js.map +1 -1
  44. package/dist/tools/analytics.d.ts.map +1 -1
  45. package/dist/tools/analytics.js +6 -3
  46. package/dist/tools/analytics.js.map +1 -1
  47. package/dist/tools/assets.d.ts.map +1 -1
  48. package/dist/tools/assets.js +20 -13
  49. package/dist/tools/assets.js.map +1 -1
  50. package/dist/tools/audio-files.d.ts.map +1 -1
  51. package/dist/tools/audio-files.js +5 -3
  52. package/dist/tools/audio-files.js.map +1 -1
  53. package/dist/tools/bulk.d.ts.map +1 -1
  54. package/dist/tools/bulk.js +15 -9
  55. package/dist/tools/bulk.js.map +1 -1
  56. package/dist/tools/collaborators.d.ts.map +1 -1
  57. package/dist/tools/collaborators.js +41 -16
  58. package/dist/tools/collaborators.js.map +1 -1
  59. package/dist/tools/comparisons.d.ts.map +1 -1
  60. package/dist/tools/comparisons.js +2 -1
  61. package/dist/tools/comparisons.js.map +1 -1
  62. package/dist/tools/credits.d.ts +4 -3
  63. package/dist/tools/credits.d.ts.map +1 -1
  64. package/dist/tools/credits.js +16 -88
  65. package/dist/tools/credits.js.map +1 -1
  66. package/dist/tools/custody.d.ts.map +1 -1
  67. package/dist/tools/custody.js +43 -10
  68. package/dist/tools/custody.js.map +1 -1
  69. package/dist/tools/dashboard.d.ts.map +1 -1
  70. package/dist/tools/dashboard.js +24 -20
  71. package/dist/tools/dashboard.js.map +1 -1
  72. package/dist/tools/deprecations.d.ts +14 -7
  73. package/dist/tools/deprecations.d.ts.map +1 -1
  74. package/dist/tools/deprecations.js +24 -17
  75. package/dist/tools/deprecations.js.map +1 -1
  76. package/dist/tools/directory.d.ts.map +1 -1
  77. package/dist/tools/directory.js +7 -4
  78. package/dist/tools/directory.js.map +1 -1
  79. package/dist/tools/discovery.d.ts +120 -11
  80. package/dist/tools/discovery.d.ts.map +1 -1
  81. package/dist/tools/discovery.js +414 -38
  82. package/dist/tools/discovery.js.map +1 -1
  83. package/dist/tools/disputes.d.ts.map +1 -1
  84. package/dist/tools/disputes.js +3 -0
  85. package/dist/tools/disputes.js.map +1 -1
  86. package/dist/tools/documents.d.ts.map +1 -1
  87. package/dist/tools/documents.js +9 -6
  88. package/dist/tools/documents.js.map +1 -1
  89. package/dist/tools/enrichment.d.ts.map +1 -1
  90. package/dist/tools/enrichment.js +92 -14
  91. package/dist/tools/enrichment.js.map +1 -1
  92. package/dist/tools/error-envelope.d.ts +44 -0
  93. package/dist/tools/error-envelope.d.ts.map +1 -0
  94. package/dist/tools/error-envelope.js +17 -0
  95. package/dist/tools/error-envelope.js.map +1 -0
  96. package/dist/tools/exports.d.ts.map +1 -1
  97. package/dist/tools/exports.js +24 -9
  98. package/dist/tools/exports.js.map +1 -1
  99. package/dist/tools/groups.d.ts.map +1 -1
  100. package/dist/tools/groups.js +1 -0
  101. package/dist/tools/groups.js.map +1 -1
  102. package/dist/tools/import-documents.d.ts.map +1 -1
  103. package/dist/tools/import-documents.js +3 -2
  104. package/dist/tools/import-documents.js.map +1 -1
  105. package/dist/tools/import.d.ts.map +1 -1
  106. package/dist/tools/import.js +71 -38
  107. package/dist/tools/import.js.map +1 -1
  108. package/dist/tools/index.d.ts +71 -4
  109. package/dist/tools/index.d.ts.map +1 -1
  110. package/dist/tools/index.js +160 -41
  111. package/dist/tools/index.js.map +1 -1
  112. package/dist/tools/licensing.d.ts.map +1 -1
  113. package/dist/tools/licensing.js +6 -2
  114. package/dist/tools/licensing.js.map +1 -1
  115. package/dist/tools/memory.d.ts.map +1 -1
  116. package/dist/tools/memory.js +8 -4
  117. package/dist/tools/memory.js.map +1 -1
  118. package/dist/tools/metadata.d.ts +7 -1
  119. package/dist/tools/metadata.d.ts.map +1 -1
  120. package/dist/tools/metadata.js +61 -12
  121. package/dist/tools/metadata.js.map +1 -1
  122. package/dist/tools/multimedia.d.ts.map +1 -1
  123. package/dist/tools/multimedia.js +7 -3
  124. package/dist/tools/multimedia.js.map +1 -1
  125. package/dist/tools/notes.d.ts.map +1 -1
  126. package/dist/tools/notes.js +9 -6
  127. package/dist/tools/notes.js.map +1 -1
  128. package/dist/tools/notifications.d.ts.map +1 -1
  129. package/dist/tools/notifications.js +16 -27
  130. package/dist/tools/notifications.js.map +1 -1
  131. package/dist/tools/people.d.ts.map +1 -1
  132. package/dist/tools/people.js +8 -5
  133. package/dist/tools/people.js.map +1 -1
  134. package/dist/tools/projects.d.ts.map +1 -1
  135. package/dist/tools/projects.js +16 -12
  136. package/dist/tools/projects.js.map +1 -1
  137. package/dist/tools/recording-custody.d.ts.map +1 -1
  138. package/dist/tools/recording-custody.js +9 -5
  139. package/dist/tools/recording-custody.js.map +1 -1
  140. package/dist/tools/recording-samples.d.ts.map +1 -1
  141. package/dist/tools/recording-samples.js +25 -6
  142. package/dist/tools/recording-samples.js.map +1 -1
  143. package/dist/tools/recordings.d.ts +10 -0
  144. package/dist/tools/recordings.d.ts.map +1 -1
  145. package/dist/tools/recordings.js +32 -19
  146. package/dist/tools/recordings.js.map +1 -1
  147. package/dist/tools/recovery-hints.d.ts.map +1 -1
  148. package/dist/tools/recovery-hints.js +58 -18
  149. package/dist/tools/recovery-hints.js.map +1 -1
  150. package/dist/tools/release-rich.js +5 -5
  151. package/dist/tools/release-rich.js.map +1 -1
  152. package/dist/tools/releases.d.ts.map +1 -1
  153. package/dist/tools/releases.js +21 -12
  154. package/dist/tools/releases.js.map +1 -1
  155. package/dist/tools/royalties.d.ts.map +1 -1
  156. package/dist/tools/royalties.js +12 -6
  157. package/dist/tools/royalties.js.map +1 -1
  158. package/dist/tools/send.d.ts.map +1 -1
  159. package/dist/tools/send.js +7 -4
  160. package/dist/tools/send.js.map +1 -1
  161. package/dist/tools/sessions.d.ts.map +1 -1
  162. package/dist/tools/sessions.js +8 -4
  163. package/dist/tools/sessions.js.map +1 -1
  164. package/dist/tools/settings.d.ts.map +1 -1
  165. package/dist/tools/settings.js +13 -6
  166. package/dist/tools/settings.js.map +1 -1
  167. package/dist/tools/share-links.d.ts.map +1 -1
  168. package/dist/tools/share-links.js +7 -3
  169. package/dist/tools/share-links.js.map +1 -1
  170. package/dist/tools/share-send.d.ts.map +1 -1
  171. package/dist/tools/share-send.js +15 -5
  172. package/dist/tools/share-send.js.map +1 -1
  173. package/dist/tools/shows.d.ts +31 -0
  174. package/dist/tools/shows.d.ts.map +1 -0
  175. package/dist/tools/shows.js +307 -0
  176. package/dist/tools/shows.js.map +1 -0
  177. package/dist/tools/skills.d.ts.map +1 -1
  178. package/dist/tools/skills.js +7 -0
  179. package/dist/tools/skills.js.map +1 -1
  180. package/dist/tools/split-sheets.d.ts.map +1 -1
  181. package/dist/tools/split-sheets.js +19 -12
  182. package/dist/tools/split-sheets.js.map +1 -1
  183. package/dist/tools/statements.js +2 -2
  184. package/dist/tools/statements.js.map +1 -1
  185. package/dist/tools/storage-config.d.ts.map +1 -1
  186. package/dist/tools/storage-config.js +5 -3
  187. package/dist/tools/storage-config.js.map +1 -1
  188. package/dist/tools/subscription.d.ts +162 -26
  189. package/dist/tools/subscription.d.ts.map +1 -1
  190. package/dist/tools/subscription.js +479 -241
  191. package/dist/tools/subscription.js.map +1 -1
  192. package/dist/tools/sync-placements.d.ts.map +1 -1
  193. package/dist/tools/sync-placements.js +10 -5
  194. package/dist/tools/sync-placements.js.map +1 -1
  195. package/dist/tools/telegram.d.ts.map +1 -1
  196. package/dist/tools/telegram.js +16 -24
  197. package/dist/tools/telegram.js.map +1 -1
  198. package/dist/tools/users.d.ts.map +1 -1
  199. package/dist/tools/users.js +12 -6
  200. package/dist/tools/users.js.map +1 -1
  201. package/dist/tools/works.d.ts.map +1 -1
  202. package/dist/tools/works.js +13 -10
  203. package/dist/tools/works.js.map +1 -1
  204. package/package.json +8 -4
  205. package/server.json +2 -2
  206. package/.claude/settings.local.json +0 -5
@@ -1,4 +1,11 @@
1
1
  // Copyright (c) 2025-2026 Withpica Ltd. All rights reserved.
2
+ // `deriveAnnotations` is a value import back into ./index.js, which imports
3
+ // this module — an ESM cycle. Safe because it is a hoisted function
4
+ // declaration: it is initialised before either module body runs, and it is
5
+ // only ever CALLED from inside a method here. Do not convert it to a const
6
+ // arrow function on the other side.
7
+ import { deriveAnnotations } from "./index.js";
8
+ import { getAliasFor } from "./deprecations.js";
2
9
  import { getToolMetadata } from "./metadata.js";
3
10
  import { RECOVERY_HINTS } from "./recovery-hints.js";
4
11
  const SCHEMA_VERSION = "2026.1";
@@ -25,6 +32,133 @@ const SCOPES_NOT_GRANTABLE_TO_STANDARD_AGENT = new Set([
25
32
  ]);
26
33
  /** Max matched tools returned by a `pica_discover({ query })` call. */
27
34
  const DISCOVER_MATCH_LIMIT = 25;
35
+ /**
36
+ * The fresh-org on-ramp, carried by BOTH pica_discover modes (ADR-294 L3).
37
+ * Static and org-blind by design so the response stays cacheable: the
38
+ * instruction is universal, not org data. One constant, because the ChatGPT
39
+ * app moved to query mode on 2026-08-10 and for sixteen days this line lived
40
+ * only on the full map — the client carrying 92% of tool calls never saw it
41
+ * (FIX_LOG 2026-08-26). Pinned in discovery.test.ts for a hit, a miss and
42
+ * the full map.
43
+ */
44
+ export const FRESH_ORG_HINT = "on a fresh or empty org, run pica_dashboard_briefing first (through pica_execute where this surface routes calls that way). if the catalog is empty it names the fastest import: paste a spotify album or playlist link.";
45
+ /**
46
+ * Words that describe the asker, not the entity. `pica_my_*` tools carry
47
+ * "my" as their DOMAIN segment, so before this filter "start my music
48
+ * catalogue and import my songs" led with pica_my_recent_questions,
49
+ * pica_my_reported_issues and pica_export_my_data — three tools about the
50
+ * caller, for a user who asked to import (prod agent_events, 2026-08-11).
51
+ * Determiners, pronouns, prepositions, conjunctions, auxiliaries and the
52
+ * LIGHT verbs (get / list / find / show / check / run / set …) that name no
53
+ * entity — "get the isrc" is about the ISRC. Eight tools carry a light verb
54
+ * as their first segment (pica_get_agent_activity, pica_find_duplicates,
55
+ * pica_run_work_cascade …); for them the entity is the next segment, and
56
+ * the verb still reaches them through the name text (+1). Domain verbs that
57
+ * discriminate inside a domain (delete, create, update, query, import …)
58
+ * stay. Before the light verbs were listed, "get the isrc from this
59
+ * spotify link" led with pica_get_agent_activity once the noise words that
60
+ * had been propping the import tool up were removed.
61
+ */
62
+ export const STOP_WORDS = new Set([
63
+ "a",
64
+ "an",
65
+ "the",
66
+ "my",
67
+ "me",
68
+ "mine",
69
+ "our",
70
+ "ours",
71
+ "your",
72
+ "yours",
73
+ "his",
74
+ "her",
75
+ "hers",
76
+ "their",
77
+ "theirs",
78
+ "its",
79
+ "it",
80
+ "i",
81
+ "we",
82
+ "you",
83
+ "they",
84
+ "them",
85
+ "us",
86
+ "this",
87
+ "that",
88
+ "these",
89
+ "those",
90
+ "and",
91
+ "or",
92
+ "of",
93
+ "to",
94
+ "for",
95
+ "in",
96
+ "on",
97
+ "at",
98
+ "by",
99
+ "with",
100
+ "from",
101
+ "into",
102
+ "across",
103
+ "about",
104
+ "as",
105
+ "per",
106
+ "is",
107
+ "are",
108
+ "am",
109
+ "be",
110
+ "was",
111
+ "were",
112
+ "do",
113
+ "does",
114
+ "did",
115
+ "can",
116
+ "could",
117
+ "would",
118
+ "should",
119
+ "will",
120
+ "shall",
121
+ "please",
122
+ "want",
123
+ "need",
124
+ "just",
125
+ "also",
126
+ "some",
127
+ "any",
128
+ "all",
129
+ "get",
130
+ "list",
131
+ "find",
132
+ "show",
133
+ "see",
134
+ "view",
135
+ "check",
136
+ "run",
137
+ "make",
138
+ "use",
139
+ "look",
140
+ "set",
141
+ ]);
142
+ /**
143
+ * Rows reserved at the tail of a capped shortlist for tools the caller
144
+ * cannot run here — a floor, not a ceiling: flagged rows also fill whatever
145
+ * the visible rows leave free, so a narrowly-scoped connection sees as many
146
+ * rows as an open one. Visible-first sorting alone let them fall off the
147
+ * end of a 25-row list, so an agent could conclude a capability was missing
148
+ * rather than gated (the Ridwayne session: five finance tools, zero
149
+ * callable). Pinned by discovery-ranking.test.ts.
150
+ */
151
+ const FLAGGED_MATCH_ROWS = 5;
152
+ /**
153
+ * Flag text for a tool the caller cannot run on this connection — surfaced
154
+ * as `surface_note` beside `callable_on_this_surface: false` in the
155
+ * shortlist row and in tool_details, so an agent learns the wall at
156
+ * discovery time. Its own key: a scope or destructive-tier `callable_note`
157
+ * carries the specific remediation (admin elevation, confirmation token)
158
+ * and must survive alongside it. Factual; names the two causes the
159
+ * predicate folds together (ADR-248: audiences ∋ surface AND scope held).
160
+ */
161
+ const NOT_CALLABLE_ON_SURFACE_NOTE = "not callable on this connection — it is listed on a different surface (stdio / lobby / web) or needs a scope this connection does not hold. prefer a tool without this note; the user can reach it from withpica.com.";
28
162
  /**
29
163
  * Hand-maintained category taxonomy for the catalog server.
30
164
  * Grouped by natural entity/capability prefix.
@@ -507,8 +641,17 @@ export const CATEGORIES = {
507
641
  depends_on: [],
508
642
  },
509
643
  subscription: {
510
- description: "view your billing state, manage your subscription, and pick a tier",
511
- tools: ["pica_subscription_status", "pica_subscription_manage"],
644
+ // Reworded 2026-08-26 (WS-B). "pick a tier" described a capacity ladder
645
+ // ADR-276 superseded, and neither "what does pica cost" nor "how do I pay"
646
+ // appeared anywhere in this bucket — so the two questions a paying user
647
+ // actually asks routed to no category at all.
648
+ description: "what does pica cost, how do I pay, and what is pica holding — read " +
649
+ "your billing state and get a pay link",
650
+ tools: [
651
+ "pica_subscription_status",
652
+ "pica_billing_pay",
653
+ "pica_subscription_manage",
654
+ ],
512
655
  depends_on: [],
513
656
  },
514
657
  profile: {
@@ -811,6 +954,11 @@ export const CATEGORIES = {
811
954
  ],
812
955
  depends_on: [],
813
956
  },
957
+ shows: {
958
+ description: "log live shows and tour credits, and confirm setlist matches against your catalogue",
959
+ tools: ["pica_log_show", "pica_shows_query", "pica_setlist_match"],
960
+ depends_on: [],
961
+ },
814
962
  };
815
963
  /**
816
964
  * Operational metadata defaults for pica_tool_details responses.
@@ -827,49 +975,167 @@ function getOperationalMeta(toolName) {
827
975
  }
828
976
  return {};
829
977
  }
978
+ /**
979
+ * Naive English singular of a search token.
980
+ *
981
+ * A wrong stem is NOT free. An `-es` strip turns `notes` into `not` — and
982
+ * `pica_notes_*` are in CATEGORIES with `notes` as their domain segment, so
983
+ * every query containing the ordinary English word "not" ("works not
984
+ * registered") scored a +6 domain hit on the notes family. The same rule
985
+ * stems `releases`→`releas`, which the query word `release` can never match,
986
+ * defeating the feature in the other direction. Both shipped on 2026-08-24
987
+ * and are pinned by the singularise unit tests.
988
+ *
989
+ * The rules, in order:
990
+ * - `ies` → `y` (categories → category)
991
+ * - `s` → "" unless the token ends `ss`/`us`/`is`, none of which is an
992
+ * English plural (address, status, analysis stay put)
993
+ *
994
+ * There is deliberately NO `-es` rule. Every domain segment in CATEGORIES is a
995
+ * plain `-s` plural (works, notes, releases, documents, statements, credits,
996
+ * shows…), so `-es` would only ever misfire: it cannot tell `releas+es` from
997
+ * `release+s`, and the wrong branch (`releas`) is one a query word can never
998
+ * reach. `boxes` therefore stems to `boxe` rather than `box` — harmless,
999
+ * because both sides of every comparison run through this same function.
1000
+ *
1001
+ * Exported for direct unit assertion in
1002
+ * `src/__tests__/tools/discovery-ranking.test.ts` — the stem is load-bearing
1003
+ * enough that it needs testing without a whole registry behind it.
1004
+ */
1005
+ export function singularise(token) {
1006
+ if (token.length > 3 && token.endsWith("ies"))
1007
+ return `${token.slice(0, -3)}y`;
1008
+ if (token.length > 2 && token.endsWith("s") && !/(?:ss|us|is)$/.test(token))
1009
+ return token.slice(0, -1);
1010
+ return token;
1011
+ }
1012
+ /** The token plus its stem, deduped — matching on either is a hit. */
1013
+ function tokenVariants(token) {
1014
+ const stem = singularise(token);
1015
+ return stem === token ? [token] : [token, stem];
1016
+ }
1017
+ /**
1018
+ * Levenshtein distance, two rolling rows. Tool names are short (< 60 chars)
1019
+ * and the registry is a few hundred entries, so the O(n·m) form is well
1020
+ * inside budget for a once-per-failed-dispatch call.
1021
+ */
1022
+ function editDistance(a, b) {
1023
+ if (a === b)
1024
+ return 0;
1025
+ if (a.length === 0)
1026
+ return b.length;
1027
+ if (b.length === 0)
1028
+ return a.length;
1029
+ let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
1030
+ let curr = new Array(b.length + 1);
1031
+ for (let i = 1; i <= a.length; i++) {
1032
+ curr[0] = i;
1033
+ for (let j = 1; j <= b.length; j++) {
1034
+ const substitution = prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1);
1035
+ curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, substitution);
1036
+ }
1037
+ [prev, curr] = [curr, prev];
1038
+ }
1039
+ return prev[b.length];
1040
+ }
1041
+ /** Length of the common leading substring — a typo late in a name keeps it. */
1042
+ function sharedPrefixLength(a, b) {
1043
+ const max = Math.min(a.length, b.length);
1044
+ let i = 0;
1045
+ while (i < max && a[i] === b[i])
1046
+ i++;
1047
+ return i;
1048
+ }
830
1049
  export class DiscoveryTools {
831
1050
  executeToolFn;
832
1051
  getToolDef;
833
1052
  listToolNames;
1053
+ isToolVisible;
834
1054
  constructor(executeToolFn, getToolDef,
835
1055
  /**
836
- * Registered tool names, for UNKNOWN_TOOL near-match suggestions.
1056
+ * Registered tool names, for TOOL_NOT_FOUND near-match suggestions.
837
1057
  * Optional so existing constructions keep compiling; without it the
838
1058
  * unknown-tool envelope simply carries no suggestions.
839
1059
  */
840
- listToolNames) {
1060
+ listToolNames,
1061
+ /**
1062
+ * Whether the CURRENT caller can run a tool on this connection. On HTTP
1063
+ * the route installs its `allowedToolNames` judgement (ADR-248:
1064
+ * audiences ∋ http AND scope held) via `ToolRegistry.setToolVisibility`;
1065
+ * the registry's default is audience-only. Absent = every registered
1066
+ * tool is treated as callable (legacy constructions, unit harnesses).
1067
+ * Consulted by the shortlist (rank visible first, flag the rest) and by
1068
+ * tool_details (flag) — never by pica_execute, which the dispatcher
1069
+ * already gates.
1070
+ */
1071
+ isToolVisible) {
841
1072
  this.executeToolFn = executeToolFn;
842
1073
  this.getToolDef = getToolDef;
843
1074
  this.listToolNames = listToolNames;
1075
+ this.isToolVisible = isToolVisible;
1076
+ }
1077
+ isVisible(toolName) {
1078
+ return this.isToolVisible ? this.isToolVisible(toolName) : true;
1079
+ }
1080
+ /** Shortlist / detail keys added only when the tool is NOT callable here. */
1081
+ surfaceFlags(toolName) {
1082
+ return this.isVisible(toolName)
1083
+ ? {}
1084
+ : {
1085
+ callable_on_this_surface: false,
1086
+ surface_note: NOT_CALLABLE_ON_SURFACE_NOTE,
1087
+ };
844
1088
  }
845
1089
  /**
846
- * Rank registered tool names by shared `_`-token overlap with a
847
- * hallucinated name. ChatGPT callers routinely invent tool names
1090
+ * Rank registered tool names against a hallucinated or mistyped one.
1091
+ *
1092
+ * ChatGPT callers routinely invent tool names
848
1093
  * (`pica_releases_attach_track_by_identifier`, `pica_credit_add`, …) and
849
1094
  * retry variations instead of consulting pica_tool_details — 26 opaque
850
1095
  * dispatcher errors in 14 days. Naming the closest real tools breaks that
851
1096
  * guess-loop.
1097
+ *
1098
+ * Token overlap alone was not enough: `pica_works_inspekt` overlaps every
1099
+ * `pica_works_*` tool on exactly one token, so the top-3 slice fell back to
1100
+ * registration order and could drop the obvious answer. Edit distance
1101
+ * breaks that tie — pinned by "ranks the nearest EDIT-distance name first"
1102
+ * in `src/__tests__/tools/discovery.test.ts`.
1103
+ *
1104
+ * Deprecated aliases are excluded. The candidate pool is
1105
+ * `[...this.tools.keys()]` (index.ts, where DiscoveryTools is constructed),
1106
+ * which includes the hidden alias clones `expandAliases` registers — so a
1107
+ * typo one letter off an alias (`pica_releases_lists`) was answered with the
1108
+ * alias (`pica_releases_list`, removal_target 2.96.0) ranked above its own
1109
+ * canonical. Steering a confused caller onto a name we are trying to retire
1110
+ * is the opposite of what this envelope is for.
852
1111
  */
853
1112
  suggestToolNames(input) {
854
- const candidates = this.listToolNames?.() ?? [];
1113
+ const candidates = (this.listToolNames?.() ?? []).filter((name) => getAliasFor(name) === undefined);
855
1114
  const tokens = new Set(input
856
1115
  .replace(/^pica_/, "")
857
1116
  .split("_")
858
1117
  .filter(Boolean));
859
1118
  if (tokens.size === 0 || candidates.length === 0)
860
1119
  return [];
861
- return candidates
1120
+ return (candidates
862
1121
  .map((name) => ({
863
1122
  name,
864
1123
  overlap: name
865
1124
  .replace(/^pica_/, "")
866
1125
  .split("_")
867
1126
  .filter((t) => tokens.has(t)).length,
1127
+ distance: editDistance(name, input),
1128
+ prefix: sharedPrefixLength(name, input),
868
1129
  }))
869
- .filter((c) => c.overlap > 0)
870
- .sort((a, b) => b.overlap - a.overlap)
1130
+ // A near-miss on spelling shares no whole token, so distance and prefix
1131
+ // have to be able to admit a candidate on their own.
1132
+ .filter((c) => c.overlap > 0 || c.distance <= 3 || c.prefix >= 8)
1133
+ .sort((a, b) => b.overlap - a.overlap ||
1134
+ a.distance - b.distance ||
1135
+ b.prefix - a.prefix ||
1136
+ a.name.localeCompare(b.name))
871
1137
  .slice(0, 3)
872
- .map((c) => c.name);
1138
+ .map((c) => c.name));
873
1139
  }
874
1140
  getTools() {
875
1141
  return [
@@ -911,6 +1177,10 @@ export class DiscoveryTools {
911
1177
  },
912
1178
  executor: async (args) => {
913
1179
  const cacheUntil = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
1180
+ // Query-mode rows carry per-connection flags since 2026-08-26 (an
1181
+ // org's finance toggle changes them), so a shortlist is cacheable for
1182
+ // an hour, not the day the org-blind full map gets.
1183
+ const queryCacheUntil = new Date(Date.now() + 60 * 60 * 1000).toISOString();
914
1184
  const rawQuery = typeof args?.query === "string" ? args.query : "";
915
1185
  const query = rawQuery.trim();
916
1186
  // Filtered mode — a job-search shortlist. Additive: when no query is
@@ -923,18 +1193,20 @@ export class DiscoveryTools {
923
1193
  ];
924
1194
  const payload = {
925
1195
  schema_version: SCHEMA_VERSION,
926
- cache_until: cacheUntil,
1196
+ cache_until: queryCacheUntil,
927
1197
  query,
928
1198
  match_count: matches.length,
929
1199
  matches: matches.map(({ tool, category, description }) => ({
930
1200
  tool,
931
1201
  category,
932
1202
  description,
1203
+ ...this.surfaceFlags(tool),
933
1204
  })),
934
1205
  matched_categories: matchedCategories,
935
- hint: matches.length > 0
1206
+ hint: (matches.length > 0
936
1207
  ? `${matches.length} tool(s) match "${query}". Call pica_tool_details({ tool: "<name>" }) for a full schema. Call pica_discover with no query to browse all ${Object.keys(CATEGORIES).length} categories.`
937
- : `no tools matched "${query}". Try different words, or call pica_discover with no query to browse all ${Object.keys(CATEGORIES).length} categories before concluding a capability is missing.`,
1208
+ : `no tools matched "${query}". Try different words, or call pica_discover with no query to browse all ${Object.keys(CATEGORIES).length} categories before concluding a capability is missing.`) +
1209
+ ` ${FRESH_ORG_HINT}`,
938
1210
  };
939
1211
  return {
940
1212
  content: [
@@ -962,7 +1234,7 @@ export class DiscoveryTools {
962
1234
  cache_until: cacheUntil,
963
1235
  // Static, org-blind by design (the response is cacheable): the
964
1236
  // fresh-org on-ramp is a universal instruction, not org data.
965
- hint: "on a fresh or empty org, run pica_dashboard_briefing first (through pica_execute where this surface routes calls that way). if the catalog is empty it names the fastest import: paste a spotify album or playlist link.",
1237
+ hint: FRESH_ORG_HINT,
966
1238
  categories,
967
1239
  };
968
1240
  return {
@@ -979,18 +1251,32 @@ export class DiscoveryTools {
979
1251
  }
980
1252
  /**
981
1253
  * Rank tools against a plain-language job description. Case-insensitive
982
- * token scoring (no external deps): each query token scores +3 when it
983
- * exactly matches an underscore-separated token of the tool name and +1
984
- * when it appears anywhere in the tool's searchable text (name +
985
- * description/triggers + its category name + category description). Tools
986
- * are deduped across cross-listed categories (keeping the highest-scoring
987
- * category), sorted by score then name, and capped.
1254
+ * token scoring (no external deps):
1255
+ *
1256
+ * +6 the token matches the tool's DOMAIN segment (the `_`-separated
1257
+ * segment after `pica_` `recordings` in `pica_recordings_delete`)
1258
+ * +3 the token matches any other `_`-separated segment of the name
1259
+ * +1 the token appears anywhere in the searchable text (name +
1260
+ * description/triggers + category name + category description)
1261
+ *
1262
+ * Both sides are compared through {@link singularise}, so "recording" and
1263
+ * "recordings" are one term. Tools are deduped across cross-listed
1264
+ * categories (keeping the highest-scoring category), sorted by score then
1265
+ * name, and capped.
1266
+ *
1267
+ * The domain weight exists because the VERB is shared by dozens of tools
1268
+ * and the ENTITY is what the user actually named: before it, "delete a
1269
+ * recording" led with `pica_agreement_types_delete` and "read a document"
1270
+ * put `pica_notifications_mark_read` above every documents tool. Both are
1271
+ * pinned in `src/__tests__/tools/discovery-ranking.test.ts`, alongside the
1272
+ * flagship isrc-from-spotify pin the weighting must not disturb.
988
1273
  */
989
1274
  matchDiscovery(query) {
990
1275
  const tokens = query
991
1276
  .toLowerCase()
992
1277
  .split(/[^a-z0-9]+/)
993
- .filter((t) => t.length >= 2);
1278
+ .filter((t) => t.length >= 2 && !STOP_WORDS.has(t))
1279
+ .map((t) => ({ raw: t, variants: tokenVariants(t) }));
994
1280
  if (tokens.length === 0)
995
1281
  return [];
996
1282
  // Best score per tool, plus the category that produced it.
@@ -1001,16 +1287,22 @@ export class DiscoveryTools {
1001
1287
  const def = this.getToolDef(toolName)?.definition;
1002
1288
  const metadata = getToolMetadata(toolName);
1003
1289
  const toolDesc = def?.description ?? metadata?.display_name ?? "";
1004
- const nameTokens = new Set(toolName
1290
+ const segments = toolName
1005
1291
  .replace(/^pica_/, "")
1006
1292
  .split("_")
1007
- .filter(Boolean));
1293
+ .filter(Boolean);
1294
+ // The entity the tool acts on. `pica_upload` has no verb segment, so
1295
+ // its only segment is both domain and verb — that is correct here.
1296
+ const domainVariants = new Set(segments.length > 0 ? tokenVariants(segments[0]) : []);
1297
+ const nameVariants = new Set(segments.flatMap(tokenVariants));
1008
1298
  const haystack = `${toolName.replace(/_/g, " ")} ${toolDesc} ${catText}`.toLowerCase();
1009
1299
  let score = 0;
1010
- for (const token of tokens) {
1011
- if (nameTokens.has(token))
1300
+ for (const { variants } of tokens) {
1301
+ if (variants.some((v) => domainVariants.has(v)))
1302
+ score += 6;
1303
+ else if (variants.some((v) => nameVariants.has(v)))
1012
1304
  score += 3;
1013
- if (haystack.includes(token))
1305
+ if (variants.some((v) => haystack.includes(v)))
1014
1306
  score += 1;
1015
1307
  }
1016
1308
  if (score === 0)
@@ -1022,13 +1314,24 @@ export class DiscoveryTools {
1022
1314
  category: key,
1023
1315
  description: this.oneLineDescription(toolName),
1024
1316
  score,
1317
+ visible: this.isVisible(toolName),
1025
1318
  });
1026
1319
  }
1027
1320
  }
1028
1321
  }
1029
- return [...best.values()]
1030
- .sort((a, b) => b.score - a.score || a.tool.localeCompare(b.tool))
1031
- .slice(0, DISCOVER_MATCH_LIMIT);
1322
+ // Callable-here first, then score, then name. A tool the caller cannot
1323
+ // run keeps a row (it exists the agent should not conclude the
1324
+ // capability is missing) but never leads. The cap holds at least
1325
+ // FLAGGED_MATCH_ROWS tail rows for those, and lets them fill any room
1326
+ // the visible rows leave, so the total never shrinks because of a scope.
1327
+ const ranked = [...best.values()].sort((a, b) => b.score - a.score || a.tool.localeCompare(b.tool));
1328
+ const visible = ranked.filter((m) => m.visible);
1329
+ const flagged = ranked.filter((m) => !m.visible);
1330
+ const flaggedShown = Math.min(flagged.length, Math.max(FLAGGED_MATCH_ROWS, DISCOVER_MATCH_LIMIT - visible.length));
1331
+ return [
1332
+ ...visible.slice(0, DISCOVER_MATCH_LIMIT - flaggedShown),
1333
+ ...flagged.slice(0, flaggedShown),
1334
+ ];
1032
1335
  }
1033
1336
  /**
1034
1337
  * A single-line description for a match row: the first sentence of the
@@ -1161,6 +1464,10 @@ export class DiscoveryTools {
1161
1464
  // Collect all recovery hints for this tool (all error codes)
1162
1465
  const hints = RECOVERY_HINTS[toolName] ?? [];
1163
1466
  const definition = registered?.definition ?? null;
1467
+ // A deprecated alias is registered as a clone of its canonical, so the
1468
+ // schema/description below ARE the canonical's — the agent gets a working
1469
+ // tool plus the name it should migrate to, not a bare refusal.
1470
+ const deprecation = getAliasFor(toolName);
1164
1471
  // Gate visibility (audit P1 #6): surface the authority tier + required
1165
1472
  // scope so an agent knows BEFORE calling whether it holds the grant, and
1166
1473
  // an explicit note when the scope is unreachable for a standard agent
@@ -1188,14 +1495,48 @@ export class DiscoveryTools {
1188
1495
  // concluded "I can see the tool but can't execute it" and given up
1189
1496
  // (observed on ChatGPT, 2026-07-08). Spell out the execution path so
1190
1497
  // the next step is unavoidable.
1191
- invoke: `if ${toolName} is not in your callable function list, run it via the pica_execute tool: ` +
1192
- `{"tool": "${toolName}", "args": { ...arguments per the schema below }}. ` +
1193
- "Never tell the user the tool is unavailable pica_execute executes every tool returned here.",
1498
+ // When the caller cannot run the tool here, the line above would be
1499
+ // false in the worst direction pica_execute refuses it (the route's
1500
+ // allowedToolNames gate) so the invoke text says so instead.
1501
+ invoke: this.isVisible(toolName)
1502
+ ? `if ${toolName} is not in your callable function list, run it via the pica_execute tool: ` +
1503
+ `{"tool": "${toolName}", "args": { ...arguments per the schema below }}. ` +
1504
+ "Never tell the user the tool is unavailable — pica_execute executes every tool returned here."
1505
+ : `pica_execute will refuse ${toolName} on this connection (see surface_note). ` +
1506
+ "Do not call it here; tell the user plainly what it needs and offer a tool without this flag.",
1194
1507
  schema: definition?.inputSchema ?? null,
1195
- annotations: definition?.annotations ?? null,
1508
+ ...(definition?.outputSchema
1509
+ ? { outputSchema: definition.outputSchema }
1510
+ : {}),
1511
+ // Derived, never the raw declaration: most tools declare no
1512
+ // `annotations` at all, so reading the declaration answered `null` for
1513
+ // the large majority of registered tools while listTools() answered real
1514
+ // hints for the same tool (measured 2026-08-24). deriveAnnotations() is
1515
+ // the one derivation both surfaces read — see index.ts and
1516
+ // src/__tests__/tools/tool-details-contract.test.ts.
1517
+ annotations: definition ? deriveAnnotations(definition) : null,
1518
+ // Every category key this tool is listed under. Cross-listed tools
1519
+ // (ADR-226 Decision 12) appear in more than one, and an agent that
1520
+ // arrived via one category otherwise cannot see the others.
1521
+ discovery_categories: Object.entries(CATEGORIES)
1522
+ .filter(([, cat]) => cat.tools.includes(toolName))
1523
+ .map(([key]) => key),
1524
+ ...(deprecation
1525
+ ? {
1526
+ deprecated: {
1527
+ use: deprecation.canonical,
1528
+ removal_target: deprecation.removal_target,
1529
+ },
1530
+ }
1531
+ : {}),
1196
1532
  tier,
1197
1533
  required_scope: requiredScope,
1534
+ // A scope / destructive-tier note stays whatever the surface says:
1535
+ // its remediation (admin elevation, confirmation token) is the part an
1536
+ // agent can act on. The surface flag rides beside it under its own
1537
+ // key (`surface_note`), never in its place.
1198
1538
  ...(callableNote ? { callable_note: callableNote } : {}),
1539
+ ...this.surfaceFlags(toolName),
1199
1540
  metadata: metadata
1200
1541
  ? {
1201
1542
  category: metadata.category,
@@ -1264,7 +1605,13 @@ export class DiscoveryTools {
1264
1605
  readOnlyHint: false,
1265
1606
  destructiveHint: false,
1266
1607
  idempotentHint: false,
1267
- openWorldHint: false,
1608
+ // TRUE, matching the `annotationOverrides.openWorldHint` above.
1609
+ // This read `false` until 2026-08-24 while the override next to it
1610
+ // said `true` — the override wins, so the declaration was never
1611
+ // published and only misled the next reader of this file. The
1612
+ // dispatcher wraps every tool, including the ones that email an
1613
+ // external recipient, so `true` is the honest value.
1614
+ openWorldHint: true,
1268
1615
  },
1269
1616
  },
1270
1617
  executor: async (args, ctx) => {
@@ -1337,17 +1684,41 @@ export class DiscoveryTools {
1337
1684
  // hallucinated variations instead of consulting the catalog.
1338
1685
  // structuredContent mirrors the envelope so telemetry and
1339
1686
  // structured-content-aware clients get the code without parsing text.
1687
+ //
1688
+ // The code is TOOL_NOT_FOUND, matching pica_tool_details' answer for
1689
+ // the same question and the ADR-149 Phase 5bcd design. It read
1690
+ // UNKNOWN_TOOL from 2026-07-03 until 2026-08-24; `suggestions` is kept
1691
+ // beside the new `did_you_mean` for callers written against that
1692
+ // shape. Both are asserted in
1693
+ // `src/__tests__/tools/discovery.test.ts`.
1694
+ //
1695
+ // `did_you_mean` is ALWAYS present, empty array included: a key that
1696
+ // only sometimes exists forces every caller to branch on `undefined`
1697
+ // before it can branch on "no suggestions".
1698
+ //
1699
+ // `retry_safe` and `retry_eligible` carry the same fact under two
1700
+ // names, deliberately. `retry_eligible` is the ADR-180 envelope field
1701
+ // that the five sibling envelopes in this file all emit, so dropping
1702
+ // it here alone would make one dispatcher error shaped unlike the
1703
+ // rest; `retry_safe` is the name the same fact wears in
1704
+ // `pica_tool_details.metadata.retry_safe`, which is where an agent
1705
+ // reads it everywhere else. Unifying the file on one key is a
1706
+ // separate change — do it for all six envelopes or none.
1340
1707
  if (!this.getToolDef(toolName)) {
1341
1708
  const suggestions = this.suggestToolNames(toolName);
1342
1709
  const envelope = {
1343
- error_code: "UNKNOWN_TOOL",
1710
+ error: "TOOL_NOT_FOUND",
1711
+ error_code: "TOOL_NOT_FOUND",
1344
1712
  tool: toolName,
1345
1713
  message: `${toolName} is not a registered tool.` +
1346
1714
  (suggestions.length > 0
1347
1715
  ? ` Closest real tools: ${suggestions.join(", ")}.`
1348
1716
  : "") +
1349
1717
  " Call pica_tool_details on the intended tool for its exact name and schema, or pica_discover to browse categories — do not guess tool names or argument shapes.",
1350
- ...(suggestions.length > 0 ? { suggestions } : {}),
1718
+ did_you_mean: suggestions,
1719
+ suggestions,
1720
+ next_tool: "pica_discover",
1721
+ retry_safe: false,
1351
1722
  retry_eligible: false,
1352
1723
  };
1353
1724
  return {
@@ -1355,9 +1726,14 @@ export class DiscoveryTools {
1355
1726
  { type: "text", text: JSON.stringify(envelope, null, 2) },
1356
1727
  ],
1357
1728
  structuredContent: {
1729
+ error: envelope.error,
1358
1730
  error_code: envelope.error_code,
1359
1731
  error_message: envelope.message,
1360
- ...(suggestions.length > 0 ? { suggestions } : {}),
1732
+ tool: toolName,
1733
+ did_you_mean: suggestions,
1734
+ suggestions,
1735
+ next_tool: "pica_discover",
1736
+ retry_safe: false,
1361
1737
  },
1362
1738
  isError: true,
1363
1739
  };