happyskills 0.46.1 → 0.47.1
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/CHANGELOG.md +22 -0
- package/package.json +1 -1
- package/src/api/client.js +6 -1
- package/src/api/repos.js +10 -1
- package/src/api/telemetry.js +37 -0
- package/src/commands/postlex.js +402 -0
- package/src/commands/postlex.test.js +303 -0
- package/src/commands/search.js +139 -6
- package/src/commands/search.test.js +122 -0
- package/src/constants.js +2 -1
- package/src/index.js +3 -2
- package/src/utils/slug_tokens.js +68 -0
- package/src/utils/slug_tokens.test.js +126 -0
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
// Unit tests for the pure logic inside cli/src/commands/postlex.js.
|
|
2
|
+
// Spec 260521-01 v2 § 7 — the implementation is correct when:
|
|
3
|
+
// - ranking with no exact match → unchanged
|
|
4
|
+
// - exact at rank 2 → promoted to rank 1
|
|
5
|
+
// - exact already at rank 1 → unchanged
|
|
6
|
+
// - exact at rank 11 → unchanged (outside top-10 window)
|
|
7
|
+
// - malformed JSON input → envelope with next_step.action === "retry_rank"
|
|
8
|
+
// - out-of-bounds candidate_id → warned + dropped, not a crash
|
|
9
|
+
|
|
10
|
+
const { describe, it } = require('node:test')
|
|
11
|
+
const assert = require('node:assert/strict')
|
|
12
|
+
const {
|
|
13
|
+
validate_ranking,
|
|
14
|
+
apply_postlex,
|
|
15
|
+
build_final_ordering,
|
|
16
|
+
determine_next_step,
|
|
17
|
+
build_retry_envelope,
|
|
18
|
+
parse_input,
|
|
19
|
+
} = require('./postlex')
|
|
20
|
+
|
|
21
|
+
// ─── Test fixtures ────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
const make_data = (names) => names.map((name, i) => ({
|
|
24
|
+
name,
|
|
25
|
+
workspace_slug: 'acme',
|
|
26
|
+
description: `desc for ${name}`,
|
|
27
|
+
match_quality: i === 0 ? 'strong' : i === 1 ? 'good' : 'partial',
|
|
28
|
+
star_count: 10 - i,
|
|
29
|
+
quality_score: 80 - i * 10,
|
|
30
|
+
}))
|
|
31
|
+
|
|
32
|
+
const make_ranking = (ids) => ids.map((cid, i) => ({
|
|
33
|
+
rank: i + 1,
|
|
34
|
+
candidate_id: cid,
|
|
35
|
+
rationale: `rationale for ${cid}`,
|
|
36
|
+
}))
|
|
37
|
+
|
|
38
|
+
// ─── apply_postlex ────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
describe('apply_postlex', () => {
|
|
41
|
+
it('returns ranking unchanged when no exact slug match exists in top-10', () => {
|
|
42
|
+
const data = make_data(['deploy-aws-lambda', 'serverless-framework', 'static-hosting'])
|
|
43
|
+
const ranking = make_ranking([1, 2, 3])
|
|
44
|
+
const r = apply_postlex('totally-different-query', ranking, data)
|
|
45
|
+
assert.equal(r.promoted, false)
|
|
46
|
+
assert.equal(r.promoted_from_rank, null)
|
|
47
|
+
assert.equal(r.exact_match_count_in_window, 0)
|
|
48
|
+
assert.deepEqual(r.reordered.map(x => x.candidate_id), [1, 2, 3])
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('promotes exact slug match from rank 2 → rank 1', () => {
|
|
52
|
+
const data = make_data(['serverless-framework', 'deploy-aws', 'static-hosting'])
|
|
53
|
+
const ranking = make_ranking([1, 2, 3])
|
|
54
|
+
const r = apply_postlex('deploy aws', ranking, data)
|
|
55
|
+
assert.equal(r.promoted, true)
|
|
56
|
+
assert.equal(r.promoted_from_rank, 2)
|
|
57
|
+
assert.equal(r.exact_match_count_in_window, 1)
|
|
58
|
+
// Now candidate_id=2 (the deploy-aws row) should be at rank 1.
|
|
59
|
+
assert.equal(r.reordered[0].candidate_id, 2)
|
|
60
|
+
assert.equal(r.reordered[0].rank, 1)
|
|
61
|
+
// Ranks should be reassigned 1..N.
|
|
62
|
+
assert.deepEqual(r.reordered.map(x => x.rank), [1, 2, 3])
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('leaves ranking unchanged when exact match is already at rank 1', () => {
|
|
66
|
+
const data = make_data(['deploy-aws', 'serverless', 'static'])
|
|
67
|
+
const ranking = make_ranking([1, 2, 3])
|
|
68
|
+
const r = apply_postlex('deploy aws', ranking, data)
|
|
69
|
+
assert.equal(r.promoted, false)
|
|
70
|
+
assert.equal(r.exact_match_count_in_window, 1)
|
|
71
|
+
assert.deepEqual(r.reordered.map(x => x.candidate_id), [1, 2, 3])
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('ignores exact match outside the top-10 window', () => {
|
|
75
|
+
const names = []
|
|
76
|
+
for (let i = 1; i <= 11; i++) names.push(i === 11 ? 'deploy-aws' : `other-${i}`)
|
|
77
|
+
const data = make_data(names)
|
|
78
|
+
// rank the 11 candidates 1..11, deploy-aws (candidate_id=11) is at rank 11
|
|
79
|
+
const ranking = make_ranking(Array.from({ length: 11 }, (_, i) => i + 1))
|
|
80
|
+
const r = apply_postlex('deploy aws', ranking, data)
|
|
81
|
+
assert.equal(r.promoted, false)
|
|
82
|
+
assert.equal(r.exact_match_count_in_window, 0)
|
|
83
|
+
// Top-10 window doesn't include rank 11 → no promotion.
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('when multiple exact matches exist, promotes the highest-ranked one', () => {
|
|
87
|
+
const data = make_data(['other', 'deploy-aws', 'something', 'deploy-aws-2'])
|
|
88
|
+
// Both rank 2 (candidate 2) and rank 4 (candidate 4) are exact for "deploy aws"
|
|
89
|
+
// because slug_tokens drops the trailing "2" (single-char skip).
|
|
90
|
+
// candidate_id=2 has rank=2; candidate_id=4 has rank=4.
|
|
91
|
+
// The algorithm picks the highest-ranked one (smallest rank value).
|
|
92
|
+
const ranking = make_ranking([1, 2, 3, 4])
|
|
93
|
+
const r = apply_postlex('deploy aws', ranking, data)
|
|
94
|
+
assert.equal(r.promoted, true)
|
|
95
|
+
assert.equal(r.promoted_from_rank, 2)
|
|
96
|
+
assert.equal(r.reordered[0].candidate_id, 2)
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
// ─── validate_ranking ─────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
describe('validate_ranking', () => {
|
|
103
|
+
const data = make_data(['a', 'b', 'c'])
|
|
104
|
+
|
|
105
|
+
it('accepts well-formed entries', () => {
|
|
106
|
+
const r = validate_ranking(make_ranking([1, 2, 3]), data)
|
|
107
|
+
assert.equal(r.valid_items.length, 3)
|
|
108
|
+
assert.equal(r.dropped.length, 0)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('drops candidate_id < 1', () => {
|
|
112
|
+
const r = validate_ranking([{ rank: 1, candidate_id: 0 }], data)
|
|
113
|
+
assert.equal(r.valid_items.length, 0)
|
|
114
|
+
assert.equal(r.dropped.length, 1)
|
|
115
|
+
assert.match(r.dropped[0].reason, /out of range/)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('drops candidate_id > data.length', () => {
|
|
119
|
+
const r = validate_ranking([{ rank: 1, candidate_id: 99 }], data)
|
|
120
|
+
assert.equal(r.valid_items.length, 0)
|
|
121
|
+
assert.match(r.dropped[0].reason, /out of range/)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('drops candidate_id that is not an integer', () => {
|
|
125
|
+
const r = validate_ranking([{ rank: 1, candidate_id: 'banana' }], data)
|
|
126
|
+
assert.equal(r.valid_items.length, 0)
|
|
127
|
+
assert.match(r.dropped[0].reason, /out of range/)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('drops entries where data row is missing name', () => {
|
|
131
|
+
const broken_data = [{ name: '' }, { name: 'real' }]
|
|
132
|
+
const r = validate_ranking([{ rank: 1, candidate_id: 1 }, { rank: 2, candidate_id: 2 }], broken_data)
|
|
133
|
+
assert.equal(r.valid_items.length, 1)
|
|
134
|
+
assert.equal(r.dropped[0].candidate_id, 1)
|
|
135
|
+
assert.match(r.dropped[0].reason, /missing name/)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('rejects non-array ranking', () => {
|
|
139
|
+
const r = validate_ranking({ not: 'an array' }, data)
|
|
140
|
+
assert.equal(r.valid_items.length, 0)
|
|
141
|
+
assert.equal(r.dropped.length, 1)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('rejects non-array data', () => {
|
|
145
|
+
const r = validate_ranking([{ rank: 1, candidate_id: 1 }], { not: 'an array' })
|
|
146
|
+
assert.equal(r.valid_items.length, 0)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('keeps some, drops some — mixed batch', () => {
|
|
150
|
+
const r = validate_ranking([
|
|
151
|
+
{ rank: 1, candidate_id: 1 },
|
|
152
|
+
{ rank: 2, candidate_id: 99 }, // out of range
|
|
153
|
+
{ rank: 3, candidate_id: 2 },
|
|
154
|
+
], data)
|
|
155
|
+
assert.equal(r.valid_items.length, 2)
|
|
156
|
+
assert.equal(r.dropped.length, 1)
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
// ─── determine_next_step ──────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
describe('determine_next_step', () => {
|
|
163
|
+
const fo = (qualities) => qualities.map((q, i) => ({
|
|
164
|
+
rank: i + 1,
|
|
165
|
+
candidate_id: i + 1,
|
|
166
|
+
slug: `acme/r-${i}`,
|
|
167
|
+
name: `r-${i}`,
|
|
168
|
+
match_quality: q,
|
|
169
|
+
rationale: '',
|
|
170
|
+
}))
|
|
171
|
+
|
|
172
|
+
it('emits present_to_user when top 3 are all strong/good', () => {
|
|
173
|
+
const ns = determine_next_step(fo(['strong', 'good', 'strong']), 'q', 0)
|
|
174
|
+
assert.equal(ns.action, 'present_to_user')
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('emits clarify when top 3 include a partial AND budget remains', () => {
|
|
178
|
+
const ns = determine_next_step(fo(['strong', 'partial', 'good']), 'q', 0)
|
|
179
|
+
assert.equal(ns.action, 'clarify')
|
|
180
|
+
assert.equal(ns.max_turns_remaining, 2)
|
|
181
|
+
assert.equal(ns.context.clarification_turns_used, 0)
|
|
182
|
+
// Always includes a "Just search anyway" option.
|
|
183
|
+
const last = ns.suggested_questions[0].options[ns.suggested_questions[0].options.length - 1]
|
|
184
|
+
assert.match(last.label, /search anyway/i)
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('emits clarify when top 3 include a weak/null AND budget remains', () => {
|
|
188
|
+
const ns = determine_next_step(fo(['weak', 'good', null]), 'q', 1)
|
|
189
|
+
assert.equal(ns.action, 'clarify')
|
|
190
|
+
assert.equal(ns.max_turns_remaining, 1)
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('emits present_to_user with budget-spent note when budget exhausted', () => {
|
|
194
|
+
const ns = determine_next_step(fo(['weak', 'partial', null]), 'q', 2)
|
|
195
|
+
assert.equal(ns.action, 'present_to_user')
|
|
196
|
+
assert.match(ns.instructions, /budget.*spent/i)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('clamps clarification_turns_used to [0, 2]', () => {
|
|
200
|
+
const ns_neg = determine_next_step(fo(['partial', 'partial', 'partial']), 'q', -1)
|
|
201
|
+
assert.equal(ns_neg.max_turns_remaining, 2)
|
|
202
|
+
const ns_big = determine_next_step(fo(['partial', 'partial', 'partial']), 'q', 99)
|
|
203
|
+
assert.equal(ns_big.action, 'present_to_user')
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('emits present_to_user when ordering is empty (no results to clarify against)', () => {
|
|
207
|
+
const ns = determine_next_step([], 'q', 0)
|
|
208
|
+
assert.equal(ns.action, 'present_to_user')
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('treats top-1-and-only-1 strong as success (less than 3 results)', () => {
|
|
212
|
+
const ns = determine_next_step(fo(['strong']), 'q', 0)
|
|
213
|
+
assert.equal(ns.action, 'present_to_user')
|
|
214
|
+
})
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
// ─── build_retry_envelope ─────────────────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
describe('build_retry_envelope', () => {
|
|
220
|
+
it('produces a retry_rank envelope with all required fields', () => {
|
|
221
|
+
const env = build_retry_envelope('the query', 'ranking missing field rationale', 1, 0)
|
|
222
|
+
assert.equal(env.data, null)
|
|
223
|
+
assert.equal(env.error.code, 'ranking_schema_mismatch')
|
|
224
|
+
assert.equal(env.error.exit_code, 0)
|
|
225
|
+
assert.equal(env.next_step.action, 'retry_rank')
|
|
226
|
+
assert.equal(env.next_step.context.clarification_turns_used, 1)
|
|
227
|
+
assert.equal(env.next_step.context.retry_count, 1)
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('increments retry_count on each call', () => {
|
|
231
|
+
const env1 = build_retry_envelope('q', 'r', 0, 0)
|
|
232
|
+
const env2 = build_retry_envelope('q', 'r', 0, env1.next_step.context.retry_count)
|
|
233
|
+
assert.equal(env2.next_step.context.retry_count, 2)
|
|
234
|
+
})
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
// ─── parse_input ──────────────────────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
describe('parse_input', () => {
|
|
240
|
+
it('parses combined ranking+data from a single JSON object', () => {
|
|
241
|
+
const raw = JSON.stringify({
|
|
242
|
+
ranking: [{ rank: 1, candidate_id: 1 }],
|
|
243
|
+
data: [{ name: 'foo' }],
|
|
244
|
+
})
|
|
245
|
+
const r = parse_input(raw, null)
|
|
246
|
+
assert.equal(r.parse_error, null)
|
|
247
|
+
assert.equal(r.ranking[0].candidate_id, 1)
|
|
248
|
+
assert.equal(r.data[0].name, 'foo')
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it('parses separate ranking + data files', () => {
|
|
252
|
+
const ranking_raw = JSON.stringify({ ranking: [{ rank: 1, candidate_id: 1 }] })
|
|
253
|
+
const data_raw = JSON.stringify({ data: [{ name: 'foo' }] })
|
|
254
|
+
const r = parse_input(ranking_raw, data_raw)
|
|
255
|
+
assert.equal(r.parse_error, null)
|
|
256
|
+
assert.equal(r.ranking.length, 1)
|
|
257
|
+
assert.equal(r.data[0].name, 'foo')
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('accepts bare arrays in either file', () => {
|
|
261
|
+
const r = parse_input(JSON.stringify([{ rank: 1, candidate_id: 1 }]), JSON.stringify([{ name: 'foo' }]))
|
|
262
|
+
assert.equal(r.parse_error, null)
|
|
263
|
+
assert.equal(r.ranking.length, 1)
|
|
264
|
+
assert.equal(r.data.length, 1)
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it('returns parse_error on malformed JSON', () => {
|
|
268
|
+
const r = parse_input('this is not json at all', null)
|
|
269
|
+
assert.equal(r.ranking, null)
|
|
270
|
+
assert.match(r.parse_error, /not valid JSON/)
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
it('returns parse_error when ranking is missing from single-payload input', () => {
|
|
274
|
+
const r = parse_input(JSON.stringify({ data: [] }), null)
|
|
275
|
+
assert.match(r.parse_error, /ranking field is missing/)
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it('returns parse_error when data is missing entirely', () => {
|
|
279
|
+
const r = parse_input(JSON.stringify({ ranking: [] }), null)
|
|
280
|
+
assert.match(r.parse_error, /data field is missing/)
|
|
281
|
+
})
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
// ─── build_final_ordering ─────────────────────────────────────────────────
|
|
285
|
+
|
|
286
|
+
describe('build_final_ordering', () => {
|
|
287
|
+
it('joins ranking with data rows, producing slug + rationale', () => {
|
|
288
|
+
const data = make_data(['deploy-aws', 'serverless'])
|
|
289
|
+
const ranking = make_ranking([1, 2])
|
|
290
|
+
const ordered = build_final_ordering(ranking, data)
|
|
291
|
+
assert.equal(ordered.length, 2)
|
|
292
|
+
assert.equal(ordered[0].slug, 'acme/deploy-aws')
|
|
293
|
+
assert.equal(ordered[0].rationale, 'rationale for 1')
|
|
294
|
+
assert.equal(ordered[0].match_quality, 'strong')
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
it('uses bare name when workspace_slug is absent', () => {
|
|
298
|
+
const data = [{ name: 'lone-skill', match_quality: 'good' }]
|
|
299
|
+
const ordered = build_final_ordering([{ rank: 1, candidate_id: 1, rationale: '' }], data)
|
|
300
|
+
assert.equal(ordered[0].slug, 'lone-skill')
|
|
301
|
+
assert.equal(ordered[0].workspace_slug, null)
|
|
302
|
+
})
|
|
303
|
+
})
|
package/src/commands/search.js
CHANGED
|
@@ -5,6 +5,7 @@ const { bold, dim, yellow, cyan, gray } = require('../ui/colors')
|
|
|
5
5
|
const { exit_with_error, UsageError, AuthError } = require('../utils/errors')
|
|
6
6
|
const { EXIT_CODES, VALID_SKILL_TYPES } = require('../constants')
|
|
7
7
|
const { load_token } = require('../auth/token_store')
|
|
8
|
+
const { fire_discovery_telemetry } = require('../api/telemetry')
|
|
8
9
|
|
|
9
10
|
const HELP_TEXT = `Usage: happyskills search [query] [options]
|
|
10
11
|
|
|
@@ -23,6 +24,12 @@ Options:
|
|
|
23
24
|
--tags <tags> Filter by tags (comma-separated)
|
|
24
25
|
--type <type> Filter by type (skill, kit)
|
|
25
26
|
--exact Force keyword-only FTS matching (skip smart routing)
|
|
27
|
+
--with-rerank Include rerank digests + next_step envelope in --json output
|
|
28
|
+
(intended for use inside an agentic session — see the
|
|
29
|
+
happyskills-help skill's discovery-protocol reference)
|
|
30
|
+
--clarification-turns-used <N>
|
|
31
|
+
Clarification budget already spent (0-2, default 0).
|
|
32
|
+
Carried across re-searches in the clarification flow.
|
|
26
33
|
--limit <n> Max results (required, 1-50)
|
|
27
34
|
--min-quality <n> Minimum quality score 0-100
|
|
28
35
|
--json Output as JSON
|
|
@@ -112,16 +119,90 @@ const to_smart_json = (item) => ({
|
|
|
112
119
|
updated_at: item.updated_at,
|
|
113
120
|
})
|
|
114
121
|
|
|
122
|
+
// Spec 260521-01 v2 § 5.1 — build the next_step envelope for a search response.
|
|
123
|
+
// Pure function: takes the response state + clarification budget + flags and
|
|
124
|
+
// returns either a NextStep object or null. Exported for testing.
|
|
125
|
+
const STRONG_TIERS = new Set(['strong', 'good'])
|
|
126
|
+
|
|
127
|
+
const build_search_next_step = (response, query, opts) => {
|
|
128
|
+
const { with_rerank, clarification_turns_used } = opts
|
|
129
|
+
if (!with_rerank) return null
|
|
130
|
+
|
|
131
|
+
const mode = response?.mode || null
|
|
132
|
+
const digests = Array.isArray(response?.rerank_digests) ? response.rerank_digests : null
|
|
133
|
+
const match_notice = response?.match_notice || null
|
|
134
|
+
const turns_used = Math.max(0, Math.min(2, clarification_turns_used | 0))
|
|
135
|
+
const turns_remaining = 2 - turns_used
|
|
136
|
+
|
|
137
|
+
// Non-semantic modes (slug, scoped, exact-FTS) don't support rerank — server
|
|
138
|
+
// returns no digests. No envelope; agent just renders.
|
|
139
|
+
if (mode !== 'semantic') return null
|
|
140
|
+
|
|
141
|
+
// Semantic + digests present → the protocol's primary path.
|
|
142
|
+
if (digests && digests.length > 0) {
|
|
143
|
+
return {
|
|
144
|
+
action: 'rank_digests_inline',
|
|
145
|
+
instructions: `Rank these candidates by how well their \`digest\` matches the query, using \`data.rerank_system_prompt\` as your system instructions. Emit JSON matching \`data.rerank_response_schema\`. Aim for ~20 items (10-30 acceptable; fewer if remaining candidates aren't clearly differentiated). Then pipe the result to \`npx happyskills postlex --query "${query}" --ranking - --clarification-turns-used ${turns_used}\`.`,
|
|
146
|
+
context: {
|
|
147
|
+
original_query: query,
|
|
148
|
+
clarification_turns_used: turns_used,
|
|
149
|
+
},
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Semantic + no digests + match_notice fires → clarify path (when budget
|
|
154
|
+
// remains) OR present_to_user with budget-spent note.
|
|
155
|
+
if (match_notice) {
|
|
156
|
+
if (turns_remaining <= 0) {
|
|
157
|
+
return {
|
|
158
|
+
action: 'present_to_user',
|
|
159
|
+
instructions: 'No top result is a strong match and the clarification budget (2 turns) is spent. Render `data.results` honestly: note the weak signal and present what you have so the user can decide. Do NOT ask another clarifying question.',
|
|
160
|
+
context: null,
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
action: 'clarify',
|
|
165
|
+
instructions: `The search returned weak results. Ask the user one of \`suggested_questions\` using your agent's question mechanism, then re-run \`npx happyskills search "<refined query>" --with-rerank --json --limit 50 --clarification-turns-used ${turns_used + 1}\`. The last option is always "Just search anyway" — honor it by re-running with the original query unchanged.`,
|
|
166
|
+
suggested_questions: [
|
|
167
|
+
{
|
|
168
|
+
question: 'Quick clarification — what specifically are you looking for?',
|
|
169
|
+
options: [
|
|
170
|
+
{ label: 'A workflow or how-to guide', refined_query_hint: 'workflow' },
|
|
171
|
+
{ label: 'A specific tool or library', refined_query_hint: 'tool' },
|
|
172
|
+
{ label: 'A platform-specific recipe (AWS / GCP / Vercel)', refined_query_hint: 'platform' },
|
|
173
|
+
{ label: 'Just search anyway', refined_query_hint: null },
|
|
174
|
+
],
|
|
175
|
+
},
|
|
176
|
+
],
|
|
177
|
+
max_turns_remaining: turns_remaining,
|
|
178
|
+
context: {
|
|
179
|
+
original_query: query,
|
|
180
|
+
clarification_turns_used: turns_used,
|
|
181
|
+
},
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Semantic, no digests, no match_notice (defensive — shouldn't normally
|
|
186
|
+
// happen). Agent renders the baseline results.
|
|
187
|
+
return null
|
|
188
|
+
}
|
|
189
|
+
|
|
115
190
|
const run_smart_search = (args, query, options) => catch_errors('Smart search failed', async () => {
|
|
116
191
|
const limit = parseInt(args.flags.limit)
|
|
117
192
|
const capped_limit = Math.min(Math.max(limit, 1), 50)
|
|
118
193
|
const min_quality = args.flags['min-quality'] != null ? parseInt(args.flags['min-quality']) : null
|
|
194
|
+
const with_rerank = !!args.flags['with-rerank']
|
|
195
|
+
const clarification_turns_used = parseInt(args.flags['clarification-turns-used'] || '0', 10) || 0
|
|
119
196
|
|
|
120
197
|
const search_opts = { ...options, limit: capped_limit }
|
|
198
|
+
if (with_rerank) search_opts.with_rerank_digests = true
|
|
199
|
+
|
|
121
200
|
const [errors, response] = await repos_api.dispatch_search(query, search_opts)
|
|
122
201
|
if (errors) throw e('Search failed', errors)
|
|
123
202
|
|
|
124
|
-
// Response shape: { data: [...], mode, workspace_match, match_notice
|
|
203
|
+
// Response shape: { data: [...], mode, workspace_match, match_notice,
|
|
204
|
+
// and when with_rerank_digests=true: rerank_digests, rerank_system_prompt,
|
|
205
|
+
// rerank_prompt_version, rerank_response_schema }
|
|
125
206
|
const items_raw = Array.isArray(response) ? response : (response?.data || response?.repos || response?.items || [])
|
|
126
207
|
let items = items_raw
|
|
127
208
|
const mode = response?.mode || null
|
|
@@ -135,12 +216,46 @@ const run_smart_search = (args, query, options) => catch_errors('Smart search fa
|
|
|
135
216
|
)
|
|
136
217
|
}
|
|
137
218
|
|
|
219
|
+
const next_step = build_search_next_step(response, query, { with_rerank, clarification_turns_used })
|
|
220
|
+
|
|
221
|
+
// Telemetry beacons (fire-and-forget). Spec § 5.1 + § 5.3.
|
|
222
|
+
if (with_rerank && next_step?.action === 'rank_digests_inline') {
|
|
223
|
+
fire_discovery_telemetry({
|
|
224
|
+
event: 'rerank_started',
|
|
225
|
+
query,
|
|
226
|
+
rerank_prompt_version: response?.rerank_prompt_version || null,
|
|
227
|
+
})
|
|
228
|
+
}
|
|
229
|
+
if (with_rerank && next_step?.action === 'clarify') {
|
|
230
|
+
fire_discovery_telemetry({
|
|
231
|
+
event: 'clarify_triggered',
|
|
232
|
+
query,
|
|
233
|
+
reason: 'match_notice',
|
|
234
|
+
turn_number: clarification_turns_used + 1,
|
|
235
|
+
})
|
|
236
|
+
}
|
|
237
|
+
if (with_rerank && clarification_turns_used > 0) {
|
|
238
|
+
// Re-searches after a clarify cycle. Fired whenever the agent comes back
|
|
239
|
+
// with a refined query.
|
|
240
|
+
fire_discovery_telemetry({
|
|
241
|
+
event: 'clarify_completed',
|
|
242
|
+
query,
|
|
243
|
+
turn_number: clarification_turns_used,
|
|
244
|
+
})
|
|
245
|
+
}
|
|
246
|
+
|
|
138
247
|
if (items.length === 0) {
|
|
139
248
|
if (args.flags.json) {
|
|
140
|
-
const data = { query, mode, results: [], count: 0 }
|
|
249
|
+
const data = { query, formulated_query: query, mode, results: [], count: 0 }
|
|
141
250
|
if (workspace_match !== undefined) data.workspace_match = workspace_match
|
|
142
251
|
if (server_match_notice) data.match_notice = server_match_notice
|
|
143
|
-
|
|
252
|
+
if (with_rerank) {
|
|
253
|
+
data.rerank_digests = response?.rerank_digests || []
|
|
254
|
+
data.rerank_system_prompt = response?.rerank_system_prompt || null
|
|
255
|
+
data.rerank_prompt_version = response?.rerank_prompt_version || null
|
|
256
|
+
data.rerank_response_schema = response?.rerank_response_schema || null
|
|
257
|
+
}
|
|
258
|
+
print_json(with_rerank ? { data, error: null, next_step } : { data })
|
|
144
259
|
return
|
|
145
260
|
}
|
|
146
261
|
if (mode === 'fuzzy_scoped' && workspace_match === null) {
|
|
@@ -167,10 +282,18 @@ const run_smart_search = (args, query, options) => catch_errors('Smart search fa
|
|
|
167
282
|
similarity: member.similarity != null ? member.similarity : null,
|
|
168
283
|
})),
|
|
169
284
|
}))
|
|
170
|
-
const data = { query, mode, results: mapped, count: mapped.length }
|
|
285
|
+
const data = { query, formulated_query: query, mode, results: mapped, count: mapped.length }
|
|
171
286
|
if (workspace_match !== undefined) data.workspace_match = workspace_match
|
|
172
287
|
if (match_notice) data.match_notice = match_notice
|
|
173
|
-
|
|
288
|
+
if (with_rerank) {
|
|
289
|
+
data.rerank_digests = response?.rerank_digests || []
|
|
290
|
+
data.rerank_system_prompt = response?.rerank_system_prompt || null
|
|
291
|
+
data.rerank_prompt_version = response?.rerank_prompt_version || null
|
|
292
|
+
data.rerank_response_schema = response?.rerank_response_schema || null
|
|
293
|
+
}
|
|
294
|
+
// Wrap in the universal envelope shape only when --with-rerank is set.
|
|
295
|
+
// Plain search keeps its existing { data: ... } shape for backward compat.
|
|
296
|
+
print_json(with_rerank ? { data, error: null, next_step } : { data })
|
|
174
297
|
return
|
|
175
298
|
}
|
|
176
299
|
|
|
@@ -183,6 +306,9 @@ const run_smart_search = (args, query, options) => catch_errors('Smart search fa
|
|
|
183
306
|
console.log(format_smart_result(item, i))
|
|
184
307
|
if (i < items.length - 1) console.log('')
|
|
185
308
|
})
|
|
309
|
+
if (with_rerank) {
|
|
310
|
+
console.log(`\n${dim('(--with-rerank set: rerank payload suppressed in human-readable mode. Use --json to consume.)')}\n`)
|
|
311
|
+
}
|
|
186
312
|
console.log(`\n${gray(`Showing ${items.length} result${items.length === 1 ? '' : 's'}. Install with: happyskills install <owner>/<name>`)}\n`)
|
|
187
313
|
})
|
|
188
314
|
|
|
@@ -242,6 +368,7 @@ const run = (args) => catch_errors('Search failed', async () => {
|
|
|
242
368
|
const query = args._.join(' ') || null
|
|
243
369
|
const { mine, personal, workspace, tags, type } = args.flags
|
|
244
370
|
const is_exact = !!args.flags.exact
|
|
371
|
+
const with_rerank = !!args.flags['with-rerank']
|
|
245
372
|
// --smart / -S accepted for backward compat (now the default when a query is provided)
|
|
246
373
|
const use_smart = query && !is_exact
|
|
247
374
|
const has_scope_flag = mine || personal || workspace
|
|
@@ -258,6 +385,12 @@ const run = (args) => catch_errors('Search failed', async () => {
|
|
|
258
385
|
throw new UsageError(`--type must be one of: ${VALID_SKILL_TYPES.join(', ')}`)
|
|
259
386
|
}
|
|
260
387
|
|
|
388
|
+
// Spec 260521-01 v2 § 5.1 — --with-rerank requires semantic dispatch; --exact
|
|
389
|
+
// forces FTS-only mode where rerank is meaningless. Refuse the combination.
|
|
390
|
+
if (with_rerank && is_exact) {
|
|
391
|
+
throw new UsageError('--with-rerank cannot be combined with --exact. The rerank protocol applies only to semantic-mode dispatches.')
|
|
392
|
+
}
|
|
393
|
+
|
|
261
394
|
const scope = mine ? 'mine' : personal ? 'personal' : undefined
|
|
262
395
|
|
|
263
396
|
if (mine || personal) {
|
|
@@ -288,4 +421,4 @@ const run = (args) => catch_errors('Search failed', async () => {
|
|
|
288
421
|
}
|
|
289
422
|
}).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
|
|
290
423
|
|
|
291
|
-
module.exports = { run }
|
|
424
|
+
module.exports = { run, build_search_next_step }
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Unit tests for the next_step envelope emission rules on
|
|
2
|
+
// cli/src/commands/search.js. Spec 260521-01 v2 § 5.1.
|
|
3
|
+
//
|
|
4
|
+
// The orchestration around the actual HTTP call is covered by manual E2E
|
|
5
|
+
// testing (spec § 7); here we lock down the pure decision logic.
|
|
6
|
+
|
|
7
|
+
const { describe, it } = require('node:test')
|
|
8
|
+
const assert = require('node:assert/strict')
|
|
9
|
+
const { build_search_next_step } = require('./search')
|
|
10
|
+
|
|
11
|
+
const make_response = (overrides = {}) => ({
|
|
12
|
+
mode: 'semantic',
|
|
13
|
+
data: [],
|
|
14
|
+
match_notice: null,
|
|
15
|
+
rerank_digests: null,
|
|
16
|
+
rerank_prompt_version: null,
|
|
17
|
+
...overrides,
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
describe('build_search_next_step', () => {
|
|
21
|
+
it('returns null when --with-rerank is not set', () => {
|
|
22
|
+
const r = build_search_next_step(make_response({ rerank_digests: [{ candidate_id: 1 }] }), 'q', {
|
|
23
|
+
with_rerank: false,
|
|
24
|
+
clarification_turns_used: 0,
|
|
25
|
+
})
|
|
26
|
+
assert.equal(r, null)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('returns null when mode is fuzzy_slug (no rerank for non-semantic modes)', () => {
|
|
30
|
+
const r = build_search_next_step(make_response({ mode: 'fuzzy_slug' }), 'q', {
|
|
31
|
+
with_rerank: true, clarification_turns_used: 0,
|
|
32
|
+
})
|
|
33
|
+
assert.equal(r, null)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('returns null when mode is fuzzy_scoped', () => {
|
|
37
|
+
const r = build_search_next_step(make_response({ mode: 'fuzzy_scoped' }), 'q', {
|
|
38
|
+
with_rerank: true, clarification_turns_used: 0,
|
|
39
|
+
})
|
|
40
|
+
assert.equal(r, null)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('emits rank_digests_inline when semantic mode + digests present', () => {
|
|
44
|
+
const r = build_search_next_step(make_response({
|
|
45
|
+
rerank_digests: [{ candidate_id: 1, digest: '...' }, { candidate_id: 2, digest: '...' }],
|
|
46
|
+
}), 'deploy aws', {
|
|
47
|
+
with_rerank: true, clarification_turns_used: 0,
|
|
48
|
+
})
|
|
49
|
+
assert.equal(r.action, 'rank_digests_inline')
|
|
50
|
+
assert.equal(r.context.original_query, 'deploy aws')
|
|
51
|
+
assert.equal(r.context.clarification_turns_used, 0)
|
|
52
|
+
assert.match(r.instructions, /happyskills postlex/)
|
|
53
|
+
// Instructions should carry the budget forward to the next call.
|
|
54
|
+
assert.match(r.instructions, /--clarification-turns-used 0/)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('propagates clarification_turns_used into the rank instructions', () => {
|
|
58
|
+
const r = build_search_next_step(make_response({
|
|
59
|
+
rerank_digests: [{ candidate_id: 1 }],
|
|
60
|
+
}), 'q', { with_rerank: true, clarification_turns_used: 1 })
|
|
61
|
+
assert.match(r.instructions, /--clarification-turns-used 1/)
|
|
62
|
+
assert.equal(r.context.clarification_turns_used, 1)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('emits clarify when semantic + no digests + match_notice fires + budget remains', () => {
|
|
66
|
+
const r = build_search_next_step(make_response({
|
|
67
|
+
match_notice: 'No strong or good matches.',
|
|
68
|
+
}), 'vague query', { with_rerank: true, clarification_turns_used: 0 })
|
|
69
|
+
assert.equal(r.action, 'clarify')
|
|
70
|
+
assert.equal(r.max_turns_remaining, 2)
|
|
71
|
+
// Always last option is "Just search anyway".
|
|
72
|
+
const opts = r.suggested_questions[0].options
|
|
73
|
+
assert.match(opts[opts.length - 1].label, /search anyway/i)
|
|
74
|
+
// Instructions carry the incremented budget forward.
|
|
75
|
+
assert.match(r.instructions, /--clarification-turns-used 1/)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('emits present_to_user (budget spent) when match_notice fires AND turns_used >= 2', () => {
|
|
79
|
+
const r = build_search_next_step(make_response({
|
|
80
|
+
match_notice: 'No strong or good matches.',
|
|
81
|
+
}), 'q', { with_rerank: true, clarification_turns_used: 2 })
|
|
82
|
+
assert.equal(r.action, 'present_to_user')
|
|
83
|
+
assert.match(r.instructions, /budget.*spent/i)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('returns null when semantic + no digests + no match_notice (no protocol applies)', () => {
|
|
87
|
+
const r = build_search_next_step(make_response(), 'q', {
|
|
88
|
+
with_rerank: true, clarification_turns_used: 0,
|
|
89
|
+
})
|
|
90
|
+
assert.equal(r, null)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('treats empty rerank_digests array as "no digests"', () => {
|
|
94
|
+
const r = build_search_next_step(make_response({ rerank_digests: [] }), 'q', {
|
|
95
|
+
with_rerank: true, clarification_turns_used: 0,
|
|
96
|
+
})
|
|
97
|
+
// No digests → would clarify if notice fired; here no notice, so null.
|
|
98
|
+
assert.equal(r, null)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('clamps clarification_turns_used to [0, 2]', () => {
|
|
102
|
+
const r_neg = build_search_next_step(make_response({ match_notice: 'weak' }), 'q', {
|
|
103
|
+
with_rerank: true, clarification_turns_used: -5,
|
|
104
|
+
})
|
|
105
|
+
assert.equal(r_neg.max_turns_remaining, 2)
|
|
106
|
+
const r_big = build_search_next_step(make_response({ match_notice: 'weak' }), 'q', {
|
|
107
|
+
with_rerank: true, clarification_turns_used: 99,
|
|
108
|
+
})
|
|
109
|
+
assert.equal(r_big.action, 'present_to_user')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('the clarify suggested_questions always includes 4 options ending in "Just search anyway"', () => {
|
|
113
|
+
const r = build_search_next_step(make_response({ match_notice: 'weak' }), 'q', {
|
|
114
|
+
with_rerank: true, clarification_turns_used: 0,
|
|
115
|
+
})
|
|
116
|
+
assert.equal(r.suggested_questions.length, 1)
|
|
117
|
+
const opts = r.suggested_questions[0].options
|
|
118
|
+
assert.equal(opts.length, 4)
|
|
119
|
+
assert.match(opts[3].label, /search anyway/i)
|
|
120
|
+
assert.equal(opts[3].refined_query_hint, null)
|
|
121
|
+
})
|
|
122
|
+
})
|
package/src/constants.js
CHANGED
package/src/index.js
CHANGED
|
@@ -36,7 +36,7 @@ const parse_args = (argv) => {
|
|
|
36
36
|
if (arg.startsWith('--')) {
|
|
37
37
|
const key = arg.slice(2)
|
|
38
38
|
const next = argv[i + 1]
|
|
39
|
-
if (!next || next.startsWith('-')) {
|
|
39
|
+
if (!next || (next.startsWith('-') && next !== '-')) {
|
|
40
40
|
args.flags[key] = true
|
|
41
41
|
} else {
|
|
42
42
|
args.flags[key] = next
|
|
@@ -45,7 +45,7 @@ const parse_args = (argv) => {
|
|
|
45
45
|
} else if (arg.startsWith('-') && arg.length === 2) {
|
|
46
46
|
const key = arg.slice(1)
|
|
47
47
|
const next = argv[i + 1]
|
|
48
|
-
if (!next || next.startsWith('-')) {
|
|
48
|
+
if (!next || (next.startsWith('-') && next !== '-')) {
|
|
49
49
|
args.flags[key] = true
|
|
50
50
|
} else {
|
|
51
51
|
args.flags[key] = next
|
|
@@ -91,6 +91,7 @@ Commands:
|
|
|
91
91
|
visibility <owner/skill> Check or set visibility (alias: vis)
|
|
92
92
|
list List installed skills (alias: ls)
|
|
93
93
|
search <query> Search the registry (alias: s)
|
|
94
|
+
postlex Apply deterministic rerank finalization (agent-only; see happyskills-help skill)
|
|
94
95
|
versions <owner/skill> List all published versions of a skill
|
|
95
96
|
changelog <owner/skill> Print a skill's CHANGELOG.md
|
|
96
97
|
check [owner/skill] Check for available updates
|