@aiwg/cli 2026.8.17 → 2026.8.19
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/bin/aiwg.mjs +24 -2
- package/dist/src/a2a/agent-card.js +4 -1
- package/dist/src/a2a/client.js +148 -68
- package/dist/src/a2a/codecs.js +480 -0
- package/dist/src/a2a/events.js +226 -0
- package/dist/src/a2a/hitl-driver.js +8 -6
- package/dist/src/a2a/hitl.js +2 -1
- package/dist/src/a2a/http.js +85 -5
- package/dist/src/a2a/protocol.js +136 -0
- package/dist/src/a2a/types.js +4 -14
- package/dist/src/a2a/webhook.js +101 -4
- package/dist/src/artifacts/index-builder.js +63 -12
- package/dist/src/artifacts/index-files.js +26 -5
- package/dist/src/artifacts/query-engine.js +67 -67
- package/dist/src/artifacts/stats.js +6 -2
- package/dist/src/artifacts/types.js +1 -1
- package/dist/src/audit/operator-decision.js +15 -1
- package/dist/src/channel/manager.mjs +89 -17
- package/dist/src/cli/handlers/index.js +3 -1
- package/dist/src/cli/handlers/installation.js +79 -0
- package/dist/src/cli/handlers/refresh.js +6 -2
- package/dist/src/cli/handlers/runtime-info.js +9 -1
- package/dist/src/cli/handlers/serve.js +107 -4
- package/dist/src/cli/handlers/session.js +12 -26
- package/dist/src/cli/handlers/use.js +19 -4
- package/dist/src/cli/handlers/utilities.js +4 -0
- package/dist/src/cli/handlers/version.js +4 -0
- package/dist/src/config/user-config-dir.mjs +29 -0
- package/dist/src/config/user-config.js +4 -22
- package/dist/src/extensions/commands/definitions.js +20 -1
- package/dist/src/features/catalog.js +2 -1
- package/dist/src/flow/graph-metadata.js +56 -0
- package/dist/src/installation/manager-command.mjs +31 -0
- package/dist/src/installation/manager.mjs +264 -0
- package/dist/src/serve/a2a-terminal-observer.js +28 -5
- package/dist/src/serve/dispatch-router.js +32 -4
- package/dist/src/serve/executor-registry.js +29 -0
- package/dist/src/serve/mission-conductor.js +15 -1
- package/dist/src/serve/stack-adapters.js +2 -1
- package/dist/src/serve/telemetry.js +5 -1
- package/dist/src/smiths/context-pipeline/claude-hook.js +8 -5
- package/dist/src/smiths/context-pipeline/line-endings.js +12 -0
- package/dist/src/smiths/context-pipeline/managed-hook.js +8 -5
- package/dist/src/smiths/context-pipeline/workspace-context.js +3 -1
- package/dist/src/update/checker.mjs +16 -15
- package/dist/src/update/notifier.mjs +8 -3
- package/dist/src/update/service.mjs +51 -5
- package/package.json +1 -1
- package/tools/agents/deploy-agents.mjs +28 -8
- package/tools/agents/providers/base.mjs +5 -3
package/dist/src/a2a/webhook.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
//
|
|
16
16
|
// @issue #1256
|
|
17
17
|
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
18
|
+
import { decodeStreamResponse, A2AEventReconciler } from './events.js';
|
|
18
19
|
/** Header name (case-insensitive). */
|
|
19
20
|
export const SIGNATURE_HEADER = 'x-aiwg-signature';
|
|
20
21
|
/** Five minutes — RFC 8941 timestamp tolerance. */
|
|
@@ -111,6 +112,7 @@ function constantTimeHexEqual(a, b) {
|
|
|
111
112
|
export class IdempotencyCache {
|
|
112
113
|
capacity;
|
|
113
114
|
seen = new Set();
|
|
115
|
+
pending = new Set();
|
|
114
116
|
order = [];
|
|
115
117
|
constructor(capacity = DEFAULT_IDEMPOTENCY_CAPACITY) {
|
|
116
118
|
this.capacity = Math.max(16, capacity);
|
|
@@ -119,6 +121,23 @@ export class IdempotencyCache {
|
|
|
119
121
|
markFresh(id) {
|
|
120
122
|
if (this.seen.has(id))
|
|
121
123
|
return false;
|
|
124
|
+
this.commit(id);
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
/** Reserve an event before parsing/routing so concurrent deliveries cannot race. */
|
|
128
|
+
begin(id) {
|
|
129
|
+
if (this.seen.has(id))
|
|
130
|
+
return 'duplicate';
|
|
131
|
+
if (this.pending.has(id))
|
|
132
|
+
return 'pending';
|
|
133
|
+
this.pending.add(id);
|
|
134
|
+
return 'fresh';
|
|
135
|
+
}
|
|
136
|
+
/** Mark a successfully routed reservation as completed. */
|
|
137
|
+
commit(id) {
|
|
138
|
+
this.pending.delete(id);
|
|
139
|
+
if (this.seen.has(id))
|
|
140
|
+
return;
|
|
122
141
|
this.seen.add(id);
|
|
123
142
|
this.order.push(id);
|
|
124
143
|
while (this.order.length > this.capacity) {
|
|
@@ -126,7 +145,10 @@ export class IdempotencyCache {
|
|
|
126
145
|
if (evicted !== undefined)
|
|
127
146
|
this.seen.delete(evicted);
|
|
128
147
|
}
|
|
129
|
-
|
|
148
|
+
}
|
|
149
|
+
/** Release a failed reservation so a later retry can be processed. */
|
|
150
|
+
release(id) {
|
|
151
|
+
this.pending.delete(id);
|
|
130
152
|
}
|
|
131
153
|
size() {
|
|
132
154
|
return this.seen.size;
|
|
@@ -134,15 +156,35 @@ export class IdempotencyCache {
|
|
|
134
156
|
}
|
|
135
157
|
export class PushSecretRegistry {
|
|
136
158
|
entries = new Map();
|
|
159
|
+
reconcilers = new Map();
|
|
137
160
|
register(entry) {
|
|
161
|
+
if (entry.protocolVersion === '1.0' && !entry.taskId) {
|
|
162
|
+
throw new Error('A2A 1.0 push config registration requires taskId ownership scope');
|
|
163
|
+
}
|
|
138
164
|
this.entries.set(entry.configId, entry);
|
|
165
|
+
if (entry.taskId) {
|
|
166
|
+
this.reconcilers.set(entry.configId, new A2AEventReconciler({
|
|
167
|
+
taskId: entry.taskId,
|
|
168
|
+
...(entry.contextId ? { contextId: entry.contextId } : {}),
|
|
169
|
+
}));
|
|
170
|
+
}
|
|
139
171
|
}
|
|
140
172
|
lookup(configId) {
|
|
141
173
|
return this.entries.get(configId) ?? null;
|
|
142
174
|
}
|
|
143
175
|
unregister(configId) {
|
|
176
|
+
this.reconcilers.delete(configId);
|
|
144
177
|
return this.entries.delete(configId);
|
|
145
178
|
}
|
|
179
|
+
reconcile(configId, event) {
|
|
180
|
+
const entry = this.entries.get(configId);
|
|
181
|
+
const eventOwner = ownerOf(event);
|
|
182
|
+
if (entry?.taskOwner && eventOwner && entry.taskOwner !== eventOwner) {
|
|
183
|
+
throw new Error(`A2A event belongs to owner ${eventOwner}, expected ${entry.taskOwner}`);
|
|
184
|
+
}
|
|
185
|
+
const reconciler = this.reconcilers.get(configId);
|
|
186
|
+
return reconciler ? reconciler.accept(event) : event;
|
|
187
|
+
}
|
|
146
188
|
/** Test/debug helper. */
|
|
147
189
|
size() {
|
|
148
190
|
return this.entries.size;
|
|
@@ -193,40 +235,83 @@ export async function handleWebhook(configId, body, signature, eventId, opts) {
|
|
|
193
235
|
// Idempotency check — duplicate event-ids are accepted with 200 but
|
|
194
236
|
// not re-routed. The executor's retry logic depends on a 2xx response
|
|
195
237
|
// to mark delivery complete; failing here would cause infinite retry.
|
|
196
|
-
const
|
|
197
|
-
|
|
238
|
+
const entryForScope = opts.registry.lookup(configId);
|
|
239
|
+
const protocolVersion = entryForScope?.protocolVersion ?? '0.3';
|
|
240
|
+
const scopedEventId = [
|
|
241
|
+
configId,
|
|
242
|
+
protocolVersion,
|
|
243
|
+
entryForScope?.taskOwner ?? '',
|
|
244
|
+
entryForScope?.taskId ?? '',
|
|
245
|
+
eventId,
|
|
246
|
+
].join('|');
|
|
247
|
+
const reservation = opts.idempotency.begin(scopedEventId);
|
|
248
|
+
if (reservation === 'duplicate') {
|
|
198
249
|
return { status: 200, body: { ok: true, deduped: true } };
|
|
199
250
|
}
|
|
251
|
+
if (reservation === 'pending') {
|
|
252
|
+
return {
|
|
253
|
+
status: 409,
|
|
254
|
+
body: errorBody('aiwg.webhook_event_in_progress', 'a concurrent delivery is still being processed'),
|
|
255
|
+
};
|
|
256
|
+
}
|
|
200
257
|
// Route the verified payload. Errors thrown here become 500 so the
|
|
201
258
|
// executor will retry — pick the abstraction carefully on the
|
|
202
259
|
// mission-state side.
|
|
203
260
|
const entry = opts.registry.lookup(configId);
|
|
204
261
|
if (!entry) {
|
|
205
262
|
// Edge case: secret was unregistered between verify and route.
|
|
263
|
+
opts.idempotency.release(scopedEventId);
|
|
206
264
|
return {
|
|
207
265
|
status: 404,
|
|
208
266
|
body: errorBody('aiwg.webhook_secret_unknown', `configId='${configId}' no longer registered`),
|
|
209
267
|
};
|
|
210
268
|
}
|
|
269
|
+
if (protocolVersion === '1.0' && opts.contentType?.split(';')[0]?.trim().toLowerCase() !== 'application/a2a+json') {
|
|
270
|
+
opts.idempotency.release(scopedEventId);
|
|
271
|
+
return {
|
|
272
|
+
status: 415,
|
|
273
|
+
body: errorBody('aiwg.webhook_content_type_invalid', 'A2A 1.0 push requires application/a2a+json'),
|
|
274
|
+
};
|
|
275
|
+
}
|
|
211
276
|
let parsed;
|
|
212
277
|
try {
|
|
213
278
|
parsed = JSON.parse(body.toString('utf8'));
|
|
214
279
|
}
|
|
215
280
|
catch (e) {
|
|
281
|
+
opts.idempotency.release(scopedEventId);
|
|
216
282
|
return {
|
|
217
283
|
status: 400,
|
|
218
284
|
body: errorBody('aiwg.webhook_body_not_json', e.message),
|
|
219
285
|
};
|
|
220
286
|
}
|
|
287
|
+
let event;
|
|
221
288
|
try {
|
|
222
|
-
|
|
289
|
+
event = decodeStreamResponse(protocolVersion, parsed, { eventId });
|
|
290
|
+
const accepted = opts.registry.reconcile(configId, event);
|
|
291
|
+
if (!accepted) {
|
|
292
|
+
opts.idempotency.commit(scopedEventId);
|
|
293
|
+
return { status: 200, body: { ok: true, deduped: true } };
|
|
294
|
+
}
|
|
295
|
+
event = accepted;
|
|
223
296
|
}
|
|
224
297
|
catch (e) {
|
|
298
|
+
opts.idempotency.release(scopedEventId);
|
|
299
|
+
return {
|
|
300
|
+
status: 400,
|
|
301
|
+
body: errorBody('aiwg.webhook_event_invalid', e.message),
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
try {
|
|
305
|
+
await opts.route(entry, event);
|
|
306
|
+
}
|
|
307
|
+
catch (e) {
|
|
308
|
+
opts.idempotency.release(scopedEventId);
|
|
225
309
|
return {
|
|
226
310
|
status: 500,
|
|
227
311
|
body: errorBody('aiwg.webhook_route_failed', e.message),
|
|
228
312
|
};
|
|
229
313
|
}
|
|
314
|
+
opts.idempotency.commit(scopedEventId);
|
|
230
315
|
return { status: 200, body: { ok: true } };
|
|
231
316
|
}
|
|
232
317
|
function errorBody(code, detail) {
|
|
@@ -237,4 +322,16 @@ function errorBody(code, detail) {
|
|
|
237
322
|
detail,
|
|
238
323
|
};
|
|
239
324
|
}
|
|
325
|
+
function ownerOf(event) {
|
|
326
|
+
const metadata = event.type === 'task'
|
|
327
|
+
? event.task.metadata
|
|
328
|
+
: event.type === 'message'
|
|
329
|
+
? event.message.metadata
|
|
330
|
+
: event.metadata;
|
|
331
|
+
const owner = metadata?.['task_owner']
|
|
332
|
+
?? metadata?.['taskOwner']
|
|
333
|
+
?? metadata?.['tenant_id']
|
|
334
|
+
?? metadata?.['tenantId'];
|
|
335
|
+
return typeof owner === 'string' && owner ? owner : undefined;
|
|
336
|
+
}
|
|
240
337
|
//# sourceMappingURL=webhook.js.map
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Artifact Index Builder
|
|
3
3
|
*
|
|
4
4
|
* Scans .aiwg/ directories, extracts metadata from artifact frontmatter,
|
|
5
|
-
* computes checksums, extracts @-mention dependencies, and builds a
|
|
5
|
+
* computes checksums, extracts @-mention and Markdown-link dependencies, and builds a
|
|
6
6
|
* structured index at .aiwg/.index/.
|
|
7
7
|
*
|
|
8
8
|
* @implements #415
|
|
@@ -68,6 +68,57 @@ export function extractMentions(content) {
|
|
|
68
68
|
}
|
|
69
69
|
return Array.from(mentions);
|
|
70
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* Extract relative Markdown links that may resolve to graph-local artifacts.
|
|
73
|
+
*
|
|
74
|
+
* External URLs, absolute paths, and anchor-only links are intentionally absent
|
|
75
|
+
* from the accepted pattern. Resolution still happens later against indexed
|
|
76
|
+
* nodes, so a parsed link outside the active graph cannot create an edge.
|
|
77
|
+
*/
|
|
78
|
+
export function extractMarkdownLinks(content) {
|
|
79
|
+
const links = new Set();
|
|
80
|
+
const pattern = /(!?)\[[^\]]+\]\((\.\/?[^)#\s]+)(?:#[^)]+)?\)/g;
|
|
81
|
+
let match;
|
|
82
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
83
|
+
if (match[1] === '!')
|
|
84
|
+
continue;
|
|
85
|
+
links.add(match[2]);
|
|
86
|
+
}
|
|
87
|
+
return Array.from(links);
|
|
88
|
+
}
|
|
89
|
+
function resolveMarkdownLinkDependency(cwd, sourcePath, rawLink, entries, graph) {
|
|
90
|
+
const target = rawLink.split('#')[0]?.trim();
|
|
91
|
+
if (!target)
|
|
92
|
+
return null;
|
|
93
|
+
const sourceFullPath = absoluteEntryPath(cwd, sourcePath, graph);
|
|
94
|
+
const targetFullPath = path.resolve(path.dirname(sourceFullPath), target);
|
|
95
|
+
let stat;
|
|
96
|
+
try {
|
|
97
|
+
stat = fs.statSync(targetFullPath);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
if (!stat.isFile())
|
|
103
|
+
return null;
|
|
104
|
+
const indexedPath = indexPathFor(cwd, targetFullPath, graph);
|
|
105
|
+
return entries[indexedPath] ? indexedPath : null;
|
|
106
|
+
}
|
|
107
|
+
function addDependencyEdge(depGraph, entries, sourcePath, targetPath, type) {
|
|
108
|
+
if (sourcePath === targetPath)
|
|
109
|
+
return false;
|
|
110
|
+
if (!depGraph[sourcePath])
|
|
111
|
+
depGraph[sourcePath] = { upstream: [], downstream: [] };
|
|
112
|
+
if (!depGraph[targetPath])
|
|
113
|
+
depGraph[targetPath] = { upstream: [], downstream: [] };
|
|
114
|
+
if (depGraph[sourcePath].upstream.some(edge => edge.path === targetPath))
|
|
115
|
+
return false;
|
|
116
|
+
depGraph[sourcePath].upstream.push({ path: targetPath, type });
|
|
117
|
+
depGraph[targetPath].downstream.push({ path: sourcePath, type });
|
|
118
|
+
if (entries[targetPath])
|
|
119
|
+
entries[targetPath].dependents.push(sourcePath);
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
71
122
|
/**
|
|
72
123
|
* Extract title from content (first # heading or frontmatter title)
|
|
73
124
|
*/
|
|
@@ -846,6 +897,7 @@ export async function buildIndex(cwd, options = {}) {
|
|
|
846
897
|
const tags = flow ? flow.tags : (Array.isArray(data.tags) ? data.tags.map(String) : []);
|
|
847
898
|
const summary = flow?.description ?? schemaDoc?.capability ?? runbook?.capability ?? extractSummary(data, body);
|
|
848
899
|
const dependencies = extractMentions(content);
|
|
900
|
+
const markdownLinks = extractMarkdownLinks(content);
|
|
849
901
|
// Discovery metadata (#1214, #1540, #1792) — meaningful for operational
|
|
850
902
|
// AIWG artifact kinds. Kept undefined on document types so the index file
|
|
851
903
|
// stays small for the common case.
|
|
@@ -879,6 +931,7 @@ export async function buildIndex(cwd, options = {}) {
|
|
|
879
931
|
checksum,
|
|
880
932
|
summary,
|
|
881
933
|
dependencies,
|
|
934
|
+
...(markdownLinks.length > 0 ? { markdownLinks } : {}),
|
|
882
935
|
dependents: [], // Computed after all entries are processed
|
|
883
936
|
...(name ? { name } : {}),
|
|
884
937
|
...(triggers && triggers.length > 0 ? { triggers } : {}),
|
|
@@ -915,6 +968,7 @@ export async function buildIndex(cwd, options = {}) {
|
|
|
915
968
|
}
|
|
916
969
|
}
|
|
917
970
|
// Build dependency graph and compute dependents
|
|
971
|
+
let markdownLinkEdgeCount = 0;
|
|
918
972
|
for (const entry of Object.values(entries)) {
|
|
919
973
|
if (!depGraph[entry.path]) {
|
|
920
974
|
depGraph[entry.path] = { upstream: [], downstream: [] };
|
|
@@ -923,17 +977,13 @@ export async function buildIndex(cwd, options = {}) {
|
|
|
923
977
|
// Normalize: check if referenced path exists in the index
|
|
924
978
|
const normalizedDep = Object.keys(entries).find(p => p === dep || p.endsWith(dep));
|
|
925
979
|
if (normalizedDep && normalizedDep !== entry.path) {
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
// Also update the dependents field on the target entry
|
|
934
|
-
if (entries[normalizedDep]) {
|
|
935
|
-
entries[normalizedDep].dependents.push(entry.path);
|
|
936
|
-
}
|
|
980
|
+
addDependencyEdge(depGraph, entries, entry.path, normalizedDep, 'depends-on');
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
for (const link of entry.markdownLinks ?? []) {
|
|
984
|
+
const normalizedDep = resolveMarkdownLinkDependency(cwd, entry.path, link, entries, graph);
|
|
985
|
+
if (normalizedDep && addDependencyEdge(depGraph, entries, entry.path, normalizedDep, 'markdown-link')) {
|
|
986
|
+
markdownLinkEdgeCount++;
|
|
937
987
|
}
|
|
938
988
|
}
|
|
939
989
|
}
|
|
@@ -1069,6 +1119,7 @@ export async function buildIndex(cwd, options = {}) {
|
|
|
1069
1119
|
tagDistribution: tagDist,
|
|
1070
1120
|
graphMetrics: {
|
|
1071
1121
|
totalEdges,
|
|
1122
|
+
markdownLinkEdges: markdownLinkEdgeCount,
|
|
1072
1123
|
...(citationMetrics ? {
|
|
1073
1124
|
canonicalEdges: citationMetrics.canonicalEdges,
|
|
1074
1125
|
outgoingDeclarations: citationMetrics.outgoingDeclarations,
|
|
@@ -31,22 +31,40 @@ export function indexPathFor(cwd, fullPath, graph) {
|
|
|
31
31
|
return toPosixPath(relative);
|
|
32
32
|
return fullPath;
|
|
33
33
|
}
|
|
34
|
-
/** Recursively find indexable files, excluding hidden directories such as .index. */
|
|
35
34
|
export function findArtifactFiles(dir, extensions = DEFAULT_INDEX_EXTENSIONS) {
|
|
35
|
+
return walkArtifactFiles(dir, extensions, new Set());
|
|
36
|
+
}
|
|
37
|
+
/** Recursively find indexable files, excluding hidden directories such as .index. */
|
|
38
|
+
function walkArtifactFiles(dir, extensions, seenRealDirs) {
|
|
36
39
|
const results = [];
|
|
37
40
|
if (!fs.existsSync(dir))
|
|
38
41
|
return results;
|
|
42
|
+
let realDir;
|
|
43
|
+
try {
|
|
44
|
+
realDir = fs.realpathSync(dir);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return results;
|
|
48
|
+
}
|
|
49
|
+
if (seenRealDirs.has(realDir))
|
|
50
|
+
return results;
|
|
51
|
+
seenRealDirs.add(realDir);
|
|
39
52
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
40
53
|
for (const entry of entries) {
|
|
41
54
|
const fullPath = path.join(dir, entry.name);
|
|
42
|
-
|
|
55
|
+
let stat;
|
|
56
|
+
try {
|
|
57
|
+
stat = fs.statSync(fullPath);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
43
60
|
continue;
|
|
44
|
-
|
|
61
|
+
}
|
|
62
|
+
if (stat.isDirectory()) {
|
|
45
63
|
if (entry.name.startsWith('.'))
|
|
46
64
|
continue;
|
|
47
|
-
results.push(...
|
|
65
|
+
results.push(...walkArtifactFiles(fullPath, extensions, seenRealDirs));
|
|
48
66
|
}
|
|
49
|
-
else if (extensions.some(extension => entry.name.endsWith(extension))) {
|
|
67
|
+
else if (stat.isFile() && extensions.some(extension => entry.name.endsWith(extension))) {
|
|
50
68
|
results.push(fullPath);
|
|
51
69
|
}
|
|
52
70
|
}
|
|
@@ -55,6 +73,9 @@ export function findArtifactFiles(dir, extensions = DEFAULT_INDEX_EXTENSIONS) {
|
|
|
55
73
|
/** Return the exact current source-file set used by a standard graph build. */
|
|
56
74
|
export async function collectGraphIndexFiles(cwd, graph) {
|
|
57
75
|
const config = graph ? GRAPH_CONFIGS[graph] : undefined;
|
|
76
|
+
if (graph && !config) {
|
|
77
|
+
throw new Error(`Unknown graph: ${graph}`);
|
|
78
|
+
}
|
|
58
79
|
const scanDirs = config
|
|
59
80
|
? config.scanDirs.map(directory => resolveGraphScanDir(cwd, directory))
|
|
60
81
|
: [resolveProjectAiwgDir(cwd)];
|
|
@@ -181,7 +181,7 @@ const SCORE_STOPWORDS = new Set([
|
|
|
181
181
|
'with', 'into', 'from', 'is', 'are', 'be', 'i', 'we', 'my',
|
|
182
182
|
// pronouns / determiners / fillers
|
|
183
183
|
'it', 'you', 'me', 'us', 'your', 'our', 'this', 'that', 'these', 'those',
|
|
184
|
-
'there', 'here', 'some', 'any', 'all', 'also', 'please', 'about',
|
|
184
|
+
'there', 'here', 'some', 'any', 'all', 'also', 'please', 'about', 'project',
|
|
185
185
|
// question words
|
|
186
186
|
'how', 'what', 'which', 'where', 'when', 'who', 'why',
|
|
187
187
|
// asking / request verbs ("find a skill that handles …")
|
|
@@ -191,6 +191,7 @@ const SCORE_STOPWORDS = new Set([
|
|
|
191
191
|
// AIWG meta-type nouns — zero discriminating signal in a discover query
|
|
192
192
|
'aiwg', 'skill', 'skills', 'agent', 'agents', 'command', 'commands',
|
|
193
193
|
'rule', 'rules', 'schema', 'schemas', 'flow', 'flows', 'workflow', 'workflows',
|
|
194
|
+
'template', 'templates',
|
|
194
195
|
]);
|
|
195
196
|
/**
|
|
196
197
|
* Tokenize a query phrase into lowercased keywords for multi-word
|
|
@@ -200,8 +201,18 @@ const SCORE_STOPWORDS = new Set([
|
|
|
200
201
|
function tokenize(text) {
|
|
201
202
|
return text
|
|
202
203
|
.toLowerCase()
|
|
203
|
-
.split(/[^a-z0-9
|
|
204
|
-
.filter(t => t.length > 1 && !SCORE_STOPWORDS.has(t))
|
|
204
|
+
.split(/[^a-z0-9]+/)
|
|
205
|
+
.filter(t => t.length > 1 && !SCORE_STOPWORDS.has(t))
|
|
206
|
+
.map(token => token.length > 4 && token.endsWith('s') && !token.endsWith('ss')
|
|
207
|
+
? token.slice(0, -1)
|
|
208
|
+
: token);
|
|
209
|
+
}
|
|
210
|
+
function matchedFieldTokens(queryTokens, field) {
|
|
211
|
+
const fieldTokens = new Set(tokenize(field));
|
|
212
|
+
return queryTokens.filter(token => fieldTokens.has(token));
|
|
213
|
+
}
|
|
214
|
+
function fieldContainsQuery(field, queryTokens) {
|
|
215
|
+
return containsTokenSequence(tokenize(field), queryTokens);
|
|
205
216
|
}
|
|
206
217
|
/**
|
|
207
218
|
* Score a metadata entry against a keyword query.
|
|
@@ -297,6 +308,7 @@ function scoreEntryDetailed(entry, text, opts = {}) {
|
|
|
297
308
|
const personaIdentitySuppressed = diagnoseFacetActivations(text).some((activation) => activation.facet === 'persona-identity' && activation.status === 'suppressed');
|
|
298
309
|
let score = 0;
|
|
299
310
|
const matches = [];
|
|
311
|
+
const creditedTokens = new Set();
|
|
300
312
|
const finish = (uncappedScore = score, cap = 1) => ({
|
|
301
313
|
score: Math.min(uncappedScore, cap),
|
|
302
314
|
diagnostic: {
|
|
@@ -311,6 +323,17 @@ function scoreEntryDetailed(entry, text, opts = {}) {
|
|
|
311
323
|
score += contribution;
|
|
312
324
|
matches.push({ ...match, contribution });
|
|
313
325
|
};
|
|
326
|
+
const addTokenMatch = (contributionPerToken, hits, match) => {
|
|
327
|
+
const newlyMatched = hits.filter(token => !creditedTokens.has(token));
|
|
328
|
+
if (newlyMatched.length === 0)
|
|
329
|
+
return;
|
|
330
|
+
newlyMatched.forEach(token => creditedTokens.add(token));
|
|
331
|
+
addMatch(contributionPerToken * newlyMatched.length, {
|
|
332
|
+
...match,
|
|
333
|
+
matched_tokens: newlyMatched,
|
|
334
|
+
query_token_coverage: tokens.length > 0 ? newlyMatched.length / tokens.length : 0,
|
|
335
|
+
});
|
|
336
|
+
};
|
|
314
337
|
// Exact-name floor (#1233) — if the query (normalized) exactly matches
|
|
315
338
|
// the entry's canonical name, this is the artifact the user is asking
|
|
316
339
|
// for and it must surface at the top regardless of how cluttered the
|
|
@@ -422,14 +445,12 @@ function scoreEntryDetailed(entry, text, opts = {}) {
|
|
|
422
445
|
});
|
|
423
446
|
}
|
|
424
447
|
else if (useMultiToken) {
|
|
425
|
-
const hits = tokens
|
|
448
|
+
const hits = matchedFieldTokens(tokens, trigger);
|
|
426
449
|
if (overlapOK(hits.length)) {
|
|
427
|
-
|
|
450
|
+
addTokenMatch(0.1 * 4, hits, {
|
|
428
451
|
field: 'trigger',
|
|
429
452
|
match: 'token-overlap',
|
|
430
453
|
value: trigger,
|
|
431
|
-
matched_tokens: hits,
|
|
432
|
-
query_token_coverage: hits.length / tokens.length,
|
|
433
454
|
});
|
|
434
455
|
}
|
|
435
456
|
}
|
|
@@ -437,7 +458,7 @@ function scoreEntryDetailed(entry, text, opts = {}) {
|
|
|
437
458
|
}
|
|
438
459
|
// Capability description (2x weight) — full phrase first, then tokens
|
|
439
460
|
if (capabilityLower) {
|
|
440
|
-
if (capabilityLower
|
|
461
|
+
if (fieldContainsQuery(capabilityLower, tokens)) {
|
|
441
462
|
addMatch(0.2 * 2, {
|
|
442
463
|
field: 'capability',
|
|
443
464
|
match: 'contained-phrase',
|
|
@@ -445,20 +466,18 @@ function scoreEntryDetailed(entry, text, opts = {}) {
|
|
|
445
466
|
});
|
|
446
467
|
}
|
|
447
468
|
else if (useMultiToken) {
|
|
448
|
-
const hits = tokens
|
|
469
|
+
const hits = matchedFieldTokens(tokens, capabilityLower);
|
|
449
470
|
if (overlapOK(hits.length)) {
|
|
450
|
-
|
|
471
|
+
addTokenMatch(0.1 * 2, hits, {
|
|
451
472
|
field: 'capability',
|
|
452
473
|
match: 'token-overlap',
|
|
453
474
|
value: entry.capability,
|
|
454
|
-
matched_tokens: hits,
|
|
455
|
-
query_token_coverage: hits.length / tokens.length,
|
|
456
475
|
});
|
|
457
476
|
}
|
|
458
477
|
}
|
|
459
478
|
}
|
|
460
479
|
// Title (3x weight)
|
|
461
|
-
if (titleLower
|
|
480
|
+
if (fieldContainsQuery(titleLower, tokens)) {
|
|
462
481
|
addMatch(0.3 * 3, {
|
|
463
482
|
field: 'title',
|
|
464
483
|
match: titleLower === lower ? 'exact' : 'contained-phrase',
|
|
@@ -469,93 +488,83 @@ function scoreEntryDetailed(entry, text, opts = {}) {
|
|
|
469
488
|
}
|
|
470
489
|
}
|
|
471
490
|
else if (useMultiToken) {
|
|
472
|
-
const hits = tokens
|
|
491
|
+
const hits = matchedFieldTokens(tokens, titleLower);
|
|
473
492
|
if (overlapOK(hits.length)) {
|
|
474
|
-
|
|
493
|
+
addTokenMatch(0.08 * 3, hits, {
|
|
475
494
|
field: 'title',
|
|
476
495
|
match: 'token-overlap',
|
|
477
496
|
value: entry.title,
|
|
478
|
-
matched_tokens: hits,
|
|
479
|
-
query_token_coverage: hits.length / tokens.length,
|
|
480
497
|
});
|
|
481
498
|
}
|
|
482
499
|
}
|
|
483
500
|
// Tags (2x weight)
|
|
484
501
|
for (const tag of tagsLower) {
|
|
485
|
-
if (tag
|
|
502
|
+
if (fieldContainsQuery(tag, tokens)) {
|
|
486
503
|
addMatch(0.2 * 2, { field: 'tag', match: 'contained-phrase', value: tag });
|
|
487
504
|
}
|
|
488
505
|
else if (useMultiToken) {
|
|
489
|
-
const hits = tokens
|
|
506
|
+
const hits = matchedFieldTokens(tokens, tag);
|
|
490
507
|
if (overlapOK(hits.length)) {
|
|
491
|
-
|
|
508
|
+
addTokenMatch(0.05 * 2, hits, {
|
|
492
509
|
field: 'tag',
|
|
493
510
|
match: 'token-overlap',
|
|
494
511
|
value: tag,
|
|
495
|
-
matched_tokens: hits,
|
|
496
|
-
query_token_coverage: hits.length / tokens.length,
|
|
497
512
|
});
|
|
498
513
|
}
|
|
499
514
|
}
|
|
500
515
|
}
|
|
501
516
|
// Structure-aware language terms (1.5x weight). These are deliberately
|
|
502
517
|
// below declared triggers/capabilities but above generic body summaries.
|
|
503
|
-
if (searchTermsLower
|
|
518
|
+
if (fieldContainsQuery(searchTermsLower, tokens)) {
|
|
504
519
|
addMatch(0.18 * 1.5, { field: 'search_terms', match: 'contained-phrase' });
|
|
505
520
|
}
|
|
506
521
|
else if (useMultiToken) {
|
|
507
|
-
const hits = tokens
|
|
522
|
+
const hits = matchedFieldTokens(tokens, searchTermsLower);
|
|
508
523
|
if (overlapOK(hits.length)) {
|
|
509
|
-
|
|
524
|
+
addTokenMatch(0.06 * 1.5, hits, {
|
|
510
525
|
field: 'search_terms',
|
|
511
526
|
match: 'token-overlap',
|
|
512
|
-
matched_tokens: hits,
|
|
513
|
-
query_token_coverage: hits.length / tokens.length,
|
|
514
527
|
});
|
|
515
528
|
}
|
|
516
529
|
}
|
|
517
530
|
// Exact declarative kind and physical source classification are compact,
|
|
518
531
|
// useful routing signals (e.g. FlowPlaybook vs OpsInventory; runbook that
|
|
519
532
|
// originated under templates/).
|
|
520
|
-
if (kindLower
|
|
533
|
+
if (fieldContainsQuery(kindLower, tokens)) {
|
|
521
534
|
addMatch(0.15, { field: 'kind', match: 'contained-phrase', value: entry.kind });
|
|
522
535
|
}
|
|
523
|
-
if (sourceTypeLower
|
|
536
|
+
if (fieldContainsQuery(sourceTypeLower, tokens)) {
|
|
524
537
|
addMatch(0.08, { field: 'source_type', match: 'contained-phrase', value: entry.sourceType });
|
|
525
538
|
}
|
|
526
539
|
// Summary (1x weight)
|
|
527
|
-
if (summaryLower
|
|
540
|
+
if (fieldContainsQuery(summaryLower, tokens)) {
|
|
528
541
|
addMatch(0.15, { field: 'summary', match: 'contained-phrase' });
|
|
529
542
|
}
|
|
530
543
|
else if (useMultiToken) {
|
|
531
|
-
const hits = tokens
|
|
544
|
+
const hits = matchedFieldTokens(tokens, summaryLower);
|
|
532
545
|
if (overlapOK(hits.length)) {
|
|
533
|
-
|
|
546
|
+
addTokenMatch(0.04, hits, {
|
|
534
547
|
field: 'summary',
|
|
535
548
|
match: 'token-overlap',
|
|
536
|
-
matched_tokens: hits,
|
|
537
|
-
query_token_coverage: hits.length / tokens.length,
|
|
538
549
|
});
|
|
539
550
|
}
|
|
540
551
|
}
|
|
541
552
|
// Path (0.5x weight)
|
|
542
|
-
if (pathLower
|
|
553
|
+
if (fieldContainsQuery(pathLower, tokens)) {
|
|
543
554
|
addMatch(0.1, { field: 'path', match: 'contained-phrase', value: entry.path });
|
|
544
555
|
}
|
|
545
556
|
else if (useMultiToken) {
|
|
546
|
-
const hits = tokens
|
|
557
|
+
const hits = matchedFieldTokens(tokens, pathLower);
|
|
547
558
|
if (overlapOK(hits.length)) {
|
|
548
|
-
|
|
559
|
+
addTokenMatch(0.03, hits, {
|
|
549
560
|
field: 'path',
|
|
550
561
|
match: 'token-overlap',
|
|
551
562
|
value: entry.path,
|
|
552
|
-
matched_tokens: hits,
|
|
553
|
-
query_token_coverage: hits.length / tokens.length,
|
|
554
563
|
});
|
|
555
564
|
}
|
|
556
565
|
}
|
|
557
566
|
// Type (0.5x weight)
|
|
558
|
-
if (typeLower
|
|
567
|
+
if (fieldContainsQuery(typeLower, tokens)) {
|
|
559
568
|
addMatch(0.1, { field: 'type', match: 'contained-phrase', value: entry.type });
|
|
560
569
|
}
|
|
561
570
|
return finish();
|
|
@@ -1078,36 +1087,27 @@ export async function discoverCapability(cwd, params) {
|
|
|
1078
1087
|
// lexical ranking so canonical domain phrases rank their owning capability
|
|
1079
1088
|
// top-K instead of being out-scored by artifacts that merely mention the
|
|
1080
1089
|
// word. Facet activation can also rescue an otherwise-empty strict pass.
|
|
1081
|
-
|
|
1082
|
-
//
|
|
1083
|
-
// (
|
|
1084
|
-
//
|
|
1085
|
-
//
|
|
1086
|
-
//
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
const relaxedFull = candidates
|
|
1098
|
-
.map(entry => {
|
|
1099
|
-
const detailed = scoreEntryDetailed(entry, params.phrase, { relaxOverlap: true });
|
|
1090
|
+
// #154 — a strict result anywhere in the corpus must not suppress relevant
|
|
1091
|
+
// partial matches for a natural-language query. Score the relaxed pass on
|
|
1092
|
+
// matched terms (unmatched terms do not divide the score), apply a noise
|
|
1093
|
+
// floor, and union it with strict matches before ranking. Word-boundary
|
|
1094
|
+
// token matching keeps this from resurrecting substring noise such as UX in
|
|
1095
|
+
// Linux.
|
|
1096
|
+
const RELAXED_MIN_SCORE = 0.02;
|
|
1097
|
+
const strictPaths = new Set(strictScored.map(result => result.entry.path));
|
|
1098
|
+
const combinedByPath = new Map(strictScored.map(result => [result.entry.path, result]));
|
|
1099
|
+
for (const entry of candidates) {
|
|
1100
|
+
const detailed = scoreEntryDetailed(entry, params.phrase, { relaxOverlap: true });
|
|
1101
|
+
if (detailed.score < RELAXED_MIN_SCORE)
|
|
1102
|
+
continue;
|
|
1103
|
+
const existing = combinedByPath.get(entry.path);
|
|
1104
|
+
if (!existing || detailed.score > existing.score) {
|
|
1105
|
+
combinedByPath.set(entry.path, { entry, score: detailed.score });
|
|
1100
1106
|
lexicalDiagnostics.set(entry.path, detailed.diagnostic);
|
|
1101
|
-
return { entry, score: detailed.score };
|
|
1102
|
-
})
|
|
1103
|
-
.filter(r => r.score >= RELAXED_MIN_SCORE)
|
|
1104
|
-
.sort(compareDiscoverResults);
|
|
1105
|
-
const relaxedScored = dedupeDiscoverResults(await applyFacetFusion(relaxedFull, candidates, params.phrase)).slice(0, limit);
|
|
1106
|
-
if (relaxedScored.length > 0) {
|
|
1107
|
-
scored = relaxedScored;
|
|
1108
|
-
relaxed = true;
|
|
1109
1107
|
}
|
|
1110
1108
|
}
|
|
1109
|
+
const scored = dedupeDiscoverResults(await applyFacetFusion(Array.from(combinedByPath.values()).sort(compareDiscoverResults), candidates, params.phrase)).slice(0, limit);
|
|
1110
|
+
const relaxed = scored.some(result => !strictPaths.has(result.entry.path));
|
|
1111
1111
|
const queryTimeMs = Date.now() - startTime;
|
|
1112
1112
|
/**
|
|
1113
1113
|
* Resolve a stored framework-graph path to an absolute AIWG_ROOT
|