shakers-cli 0.17.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -5
- package/bin/command-audit-worker.js +19 -0
- package/bin/login.js +4 -8
- package/bin/mcp.js +29 -19
- package/bin/register.js +10 -29
- package/bin/shakers-cli.js +14 -9
- package/package.json +1 -1
- package/src/agents-client.js +1 -0
- package/src/ai-usage-review.js +5 -3
- package/src/auth-client.js +7 -107
- package/src/auth-session-store.js +6 -12
- package/src/backend-request.js +1 -24
- package/src/cli-skill.js +77 -0
- package/src/command-audit.js +47 -4
- package/src/config.js +0 -16
- package/src/consent-flow.js +7 -2
- package/src/detect-repos.js +4 -8
- package/src/detectors.js +23 -0
- package/src/device-auth-store.js +2 -2
- package/src/device-login.js +11 -9
- package/src/env-paths.js +9 -1
- package/src/i18n.js +162 -88
- package/src/mcp-ai-usage-result-tool.js +6 -4
- package/src/mcp-ai-usage-tool.js +72 -46
- package/src/mcp-choice.js +171 -34
- package/src/mcp-detector.js +26 -2
- package/src/mcp-device-login-tools.js +40 -8
- package/src/mcp-install.js +15 -11
- package/src/mcp-instructions.js +43 -17
- package/src/mcp-onboarding-tools.js +1 -3
- package/src/mcp-prompts.js +6 -4
- package/src/mcp-register-tools.js +25 -14
- package/src/mcp-role-tools.js +35 -17
- package/src/mcp-server-name.js +7 -0
- package/src/mcp-server.js +35 -7
- package/src/mcp-signup-client.js +3 -6
- package/src/mcp-signup-flow.js +389 -0
- package/src/mcp-signup-tools.js +237 -126
- package/src/onboarding-flow.js +30 -125
- package/src/opencode-sessions.js +92 -0
- package/src/profile-agents.js +26 -0
- package/src/saved-positions-flow.js +3 -3
- package/src/scanner.js +104 -12
- package/src/session-gate.js +1 -4
- package/src/session-normalizers.js +114 -8
- package/src/session-refresh.js +5 -16
- package/src/session-scan.js +167 -22
- package/src/share-payload.js +4 -1
- package/src/share.js +2 -2
- package/src/signup-client.js +12 -39
- package/src/signup-copy.js +380 -68
- package/src/skill-install.js +102 -0
- package/src/tier-engine.js +14 -2
- package/src/trust-narration.js +32 -0
- package/src/usage-run-steps.js +12 -3
- package/src/usage-scan.js +3 -0
- package/src/brand-svg.js +0 -9
- package/src/inter-fonts.js +0 -6
- package/src/mcp-auth-form.js +0 -484
package/src/mcp-ai-usage-tool.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const { execFileSync } = require('child_process');
|
|
5
5
|
const { isRefusedWalkRoot } = require('./scan-exclusions');
|
|
6
|
-
const { askChoice, chatChoice, queueNotice } = require('./mcp-choice');
|
|
6
|
+
const { askChoice, chatChoice, forChat, handOver, narrationOf, queueNotice, settle, withNextSay } = require('./mcp-choice');
|
|
7
7
|
|
|
8
8
|
function isExistingDir(p) {
|
|
9
9
|
try {
|
|
@@ -76,6 +76,7 @@ function buildArgv({ root, repoScope, lang }) {
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
const VERBATIM = 'Print relayVerbatim word for word now, as its own block, with nothing of your own added to it.';
|
|
79
|
+
const CONSENT_QUESTION = 'ai_usage:consent';
|
|
79
80
|
|
|
80
81
|
const UPLOAD_INPUT_SCHEMA = {
|
|
81
82
|
type: 'object',
|
|
@@ -103,6 +104,7 @@ function makeAiUsageTools(deps = {}) {
|
|
|
103
104
|
const {
|
|
104
105
|
scanUsage = (argv) => require('./usage-scan').scanUsage(argv),
|
|
105
106
|
autoShare = require('./share').autoShare,
|
|
107
|
+
logCommandRun = require('./command-audit').logCommandRun,
|
|
106
108
|
recordConsent = require('./share').recordConsent,
|
|
107
109
|
sendThrottled = () => require('./share').isThrottled(require('./share').loadConsentState()),
|
|
108
110
|
isValidEmail = require('./share').isValidEmail,
|
|
@@ -121,6 +123,8 @@ function makeAiUsageTools(deps = {}) {
|
|
|
121
123
|
renderFullReportText = require('./render-report-text').renderFullReportText,
|
|
122
124
|
detectFlowLang = require('./i18n').detectFlowLang,
|
|
123
125
|
aiFluencyWaitMs = 30000,
|
|
126
|
+
signupInventoryWaitMs = 20000,
|
|
127
|
+
signupProfileWaitMs = 25000,
|
|
124
128
|
signupPhase = () => require('./mcp-signup-tools').signupPhase(),
|
|
125
129
|
usageDisclaimers = (lang) => require('./onboarding-flow').usageDisclaimers(lang),
|
|
126
130
|
signupCopy = require('./signup-copy').signupCopy,
|
|
@@ -132,6 +136,8 @@ function makeAiUsageTools(deps = {}) {
|
|
|
132
136
|
let job = null;
|
|
133
137
|
// The talent's answer in the consent dialog, so a later call cannot contradict it.
|
|
134
138
|
let dialogConsent = null;
|
|
139
|
+
// In the sign-up a consent counts only after its question and the two disclaimers went to the chat.
|
|
140
|
+
let consentAsked = false;
|
|
135
141
|
let pending = null;
|
|
136
142
|
|
|
137
143
|
function startSignupScan(argv, root, lang) {
|
|
@@ -206,20 +212,19 @@ function makeAiUsageTools(deps = {}) {
|
|
|
206
212
|
const facts = reviewFacts(review);
|
|
207
213
|
const texts = [reviewTexts.summaryText(review.lang, facts), reviewTexts.explanationText(review.lang)];
|
|
208
214
|
if (ctx && ctx.elicitation && typeof ctx.elicit === 'function') {
|
|
209
|
-
const reply = await ctx.elicit({ message: [...texts, c.question].join('\n\n'), requestedSchema: reviewSchema(c, facts) });
|
|
215
|
+
const reply = await ctx.elicit({ message: [...narrationOf(ctx), ...texts, c.question].join('\n\n'), requestedSchema: reviewSchema(c, facts) });
|
|
210
216
|
const content = reply && reply.action === 'accept' && reply.content ? reply.content : null;
|
|
211
217
|
if (content && content.decision === c.yes) return upload(review, exclusionsFromForm(review, facts, content));
|
|
212
218
|
if ((content && content.decision === c.no) || (reply && reply.action === 'decline')) return declineUpload(review);
|
|
213
219
|
}
|
|
214
220
|
review.askedInChat = true;
|
|
215
|
-
const
|
|
221
|
+
const { choiceMessage, ...shown } = chatChoice(c.question, [c.yes, c.no], [...texts, c.excludeHint]);
|
|
216
222
|
return {
|
|
217
223
|
ok: false,
|
|
218
224
|
reason: 'upload-approval-required',
|
|
219
|
-
|
|
220
|
-
options: chat.options,
|
|
225
|
+
...shown,
|
|
221
226
|
excludable: { repos: facts.repos, agents: facts.agents, excerpts: facts.conversationExcerpts + facts.commandExcerpts > 0 },
|
|
222
|
-
message: `Nothing has been sent. ${
|
|
227
|
+
message: `Nothing has been sent. ${choiceMessage} Then call ai_usage_upload with confirm:true only if they pick "${c.yes}" (adding exclude with the repos and agents they want left out, exactly as listed in excludable, and excerpts:true if they want no text excerpts), or confirm:false if they pick "${c.no}". Only the talent decides: never answer for them.`,
|
|
223
228
|
};
|
|
224
229
|
}
|
|
225
230
|
|
|
@@ -246,7 +251,8 @@ function makeAiUsageTools(deps = {}) {
|
|
|
246
251
|
let send;
|
|
247
252
|
try {
|
|
248
253
|
recordConsent('granted', session.email, { verified: true });
|
|
249
|
-
|
|
254
|
+
const cliCommandId = await logCommandRun({ command: 'mcp:ai_usage_upload:send' });
|
|
255
|
+
send = await autoShare(trimmed.report, trimmed.maturity, { root: review.root, bypassThrottle: review.registration, cliCommandId });
|
|
250
256
|
} catch (error) {
|
|
251
257
|
send = { ok: false, reason: 'upload-failed', message: error && error.message ? error.message : String(error) };
|
|
252
258
|
}
|
|
@@ -257,36 +263,32 @@ function makeAiUsageTools(deps = {}) {
|
|
|
257
263
|
const excluded = exclude.repos.length > 0 || exclude.agents.length > 0 || !exclude.excerpts;
|
|
258
264
|
const confirmation = excluded ? [c.uploaded, sent] : [c.uploaded];
|
|
259
265
|
review.outcome = { ok: true, uploaded: true, send, sentReport: trimmed, sent };
|
|
260
|
-
if (review.registration)
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
}
|
|
266
|
-
return Object.assign(review.outcome,
|
|
266
|
+
if (!review.registration) return Object.assign(review.outcome, await afterUpload(review, trimmed, confirmation));
|
|
267
|
+
// The sign-up shows the same result as outside it, at the same moment, on the next question: the review already
|
|
268
|
+
// explained the two axes and how to disagree, so the result does not repeat them.
|
|
269
|
+
const result = await afterUpload(review, trimmed, confirmation, { inventoryMs: signupInventoryWaitMs, profileMs: signupProfileWaitMs, explained: true });
|
|
270
|
+
queueNotice([result.relayVerbatim, result.text].filter(Boolean).join('\n\n'));
|
|
271
|
+
const { relayVerbatim: _shown, message: _rule, ...rest } = result;
|
|
272
|
+
return Object.assign(review.outcome, rest, {
|
|
273
|
+
message: `Uploaded. Say nothing about it: the result of the analysis, which the talent reads word for word, comes with the next question.${result.next ? ' Shakers is still evaluating part of it (normal, not an error): do not wait for it.' : ''}`,
|
|
274
|
+
});
|
|
267
275
|
}
|
|
268
276
|
|
|
269
|
-
|
|
270
|
-
|
|
277
|
+
// The classified agents and the profile result are awaited together, so the answer stays under the client's cut.
|
|
278
|
+
async function afterUpload(review, { report, maturity }, confirmation, { inventoryMs = inlineWaitMs, profileMs = aiFluencyWaitMs, explained = false } = {}) {
|
|
271
279
|
const hasAgents = Array.isArray(report.agents) && report.agents.length > 0;
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
if (hasAgents) {
|
|
280
|
+
const classify = async () => {
|
|
281
|
+
if (!hasAgents) return null;
|
|
275
282
|
const session = loadAuthSession();
|
|
276
283
|
const endpoint = getUsageDiscoveredInventoryEndpoint();
|
|
277
|
-
if (sessionStatus(session)
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
let aiProfile = { status: 'unavailable', preview: null };
|
|
286
|
-
const usageSession = loadAuthSession();
|
|
287
|
-
if (sessionStatus(usageSession) === 'active') {
|
|
288
|
-
aiProfile = await resolveAiProfilePreview(
|
|
289
|
-
{ session: usageSession, endpoint: getAiProfileEndpoint(), timeoutMs: aiFluencyWaitMs },
|
|
284
|
+
if (sessionStatus(session) !== 'active' || !endpoint) return null;
|
|
285
|
+
return pollClassifiedInventory({ accessToken: session.accessToken, endpoint, requestDiscoveredInventory, capMs: inventoryMs });
|
|
286
|
+
};
|
|
287
|
+
const profile = async () => {
|
|
288
|
+
const usageSession = loadAuthSession();
|
|
289
|
+
if (sessionStatus(usageSession) !== 'active') return { status: 'unavailable', preview: null };
|
|
290
|
+
return resolveAiProfilePreview(
|
|
291
|
+
{ session: usageSession, endpoint: getAiProfileEndpoint(), timeoutMs: profileMs },
|
|
290
292
|
{
|
|
291
293
|
refreshToken: async () => {
|
|
292
294
|
const fresh = await ensureFreshSession(process.env);
|
|
@@ -294,7 +296,10 @@ function makeAiUsageTools(deps = {}) {
|
|
|
294
296
|
},
|
|
295
297
|
},
|
|
296
298
|
);
|
|
297
|
-
}
|
|
299
|
+
};
|
|
300
|
+
const [polled, aiProfile] = await Promise.all([classify(), profile()]);
|
|
301
|
+
const enrichedAgents = polled && polled.status === 'ready' ? polled.agents : null;
|
|
302
|
+
const enrichment = !hasAgents ? 'none' : enrichedAgents ? 'ready' : 'pending';
|
|
298
303
|
const displayAgents = enrichedAgents
|
|
299
304
|
? enrichedAgents.map((a) => ({ name: a.name, category: a.category, role: a.role }))
|
|
300
305
|
: (report.agents || []).map((a) => ({ name: a.name }));
|
|
@@ -306,7 +311,7 @@ function makeAiUsageTools(deps = {}) {
|
|
|
306
311
|
agents: enrichedAgents,
|
|
307
312
|
text,
|
|
308
313
|
aiProfile,
|
|
309
|
-
relayVerbatim: [...confirmation, reviewTexts.resultText(review.lang, aiProfile.status === 'ready' ? aiProfile.preview : null)].join('\n\n'),
|
|
314
|
+
relayVerbatim: [...confirmation, reviewTexts.resultText(review.lang, aiProfile.status === 'ready' ? aiProfile.preview : null, { explained })].join('\n\n'),
|
|
310
315
|
next: enrichment === 'pending' || aiProfile.status !== 'ready' ? 'ai_usage_result' : null,
|
|
311
316
|
message: `Uploaded. ${VERBATIM} Then show \`text\`, the full report, verbatim.${enrichment === 'pending' || aiProfile.status !== 'ready' ? ' Shakers is still evaluating (normal, not an error): call ai_usage_result in a few minutes for the result and its explanation.' : ''}`,
|
|
312
317
|
display: {
|
|
@@ -336,27 +341,47 @@ function makeAiUsageTools(deps = {}) {
|
|
|
336
341
|
return { ok: false, reason: 'consent-declined', relayVerbatim: signupCopy(lang).aiUsageSkipped, message: 'Nothing was scanned. Show relayVerbatim once, word for word.' };
|
|
337
342
|
}
|
|
338
343
|
|
|
344
|
+
// The yes of a dialog in the sign-up: the disclaimers come in a second dialog, so they are read before anything is scanned.
|
|
345
|
+
async function confirmInDialog(ctx, c, texts) {
|
|
346
|
+
const again = await askChoice(ctx, { texts, question: c.aiUsageStart, options: [c.aiUsageYes, c.aiUsageSkip] });
|
|
347
|
+
return again.answer || null;
|
|
348
|
+
}
|
|
349
|
+
|
|
339
350
|
async function askConsent(lang, ctx) {
|
|
340
351
|
const c = signupCopy(lang);
|
|
341
|
-
const
|
|
352
|
+
const disclaimers = Object.values(usageDisclaimers(lang));
|
|
353
|
+
// In the sign-up the question goes first and short; its disclaimers are read only after a yes, before any scan.
|
|
354
|
+
const signup = signupPhase() === 'account';
|
|
355
|
+
const question = signup ? c.aiUsageAsk : c.aiUsageQuestion;
|
|
356
|
+
const texts = signup ? [] : disclaimers;
|
|
357
|
+
const options = [c.aiUsageYes, c.aiUsageSkip];
|
|
342
358
|
const asked = ctx && ctx.elicitation
|
|
343
|
-
? await askChoice(ctx, { texts, question
|
|
344
|
-
: { chat: chatChoice(
|
|
345
|
-
|
|
359
|
+
? await askChoice(ctx, { texts, question, options })
|
|
360
|
+
: { chat: chatChoice(question, options, texts) };
|
|
361
|
+
const answer = signup && asked.answer === c.aiUsageYes ? await confirmInDialog(ctx, c, disclaimers) : asked.answer;
|
|
362
|
+
if (answer === c.aiUsageSkip) {
|
|
346
363
|
dialogConsent = 'denied';
|
|
347
364
|
return declined(lang);
|
|
348
365
|
}
|
|
349
|
-
if (
|
|
366
|
+
if (answer === c.aiUsageYes) {
|
|
350
367
|
dialogConsent = 'granted';
|
|
351
368
|
return { ok: true, reason: 'consent-granted', consent: { granted: true }, message: 'The talent said yes in the dialog: do not ask again. Call ai_usage with consent.granted:true (and their email) to run it.' };
|
|
352
369
|
}
|
|
353
|
-
|
|
370
|
+
consentAsked = true;
|
|
371
|
+
const chat = asked.chat || chatChoice(question, options, texts);
|
|
372
|
+
const reply = {
|
|
354
373
|
ok: false,
|
|
355
374
|
reason: 'consent-required',
|
|
356
375
|
...(asked.dismissed ? { dismissed: asked.dismissed } : {}),
|
|
357
|
-
...
|
|
358
|
-
message: `Nothing was scanned. ${
|
|
376
|
+
...forChat(chat),
|
|
377
|
+
message: `Nothing was scanned. ${chat.choiceMessage} Then call ai_usage again with consent.granted:true only after they pick the first option, or consent.granted:false if they skip it.`,
|
|
359
378
|
};
|
|
379
|
+
if (!signup) return reply;
|
|
380
|
+
// A yes hands over the disclaimers, written before the scan starts; a skip hands over its fixed line, which the next question would carry.
|
|
381
|
+
return withNextSay(reply, [
|
|
382
|
+
handOver(CONSENT_QUESTION, { step: 'ai_usage', ifAnswer: [c.aiUsageYes], texts: disclaimers }),
|
|
383
|
+
handOver(CONSENT_QUESTION, { step: 'interview', ifAnswer: [c.aiUsageSkip], texts: [c.aiUsageSkipped] }),
|
|
384
|
+
]);
|
|
360
385
|
}
|
|
361
386
|
|
|
362
387
|
async function handler(args = {}, ctx = {}) {
|
|
@@ -369,7 +394,8 @@ function makeAiUsageTools(deps = {}) {
|
|
|
369
394
|
if (phase === 'waiting') {
|
|
370
395
|
return { ok: false, reason: 'account-pending', message: 'Nothing was scanned. Ask about AI usage only once signup_status says the account exists.' };
|
|
371
396
|
}
|
|
372
|
-
const consent = args.consent || {};
|
|
397
|
+
const consent = phase === 'account' && !consentAsked && dialogConsent === null ? {} : args.consent || {};
|
|
398
|
+
if (typeof consent.granted === 'boolean') settle(CONSENT_QUESTION, consent.granted ? signupCopy(textLang).aiUsageYes : signupCopy(textLang).aiUsageSkip);
|
|
373
399
|
if (consent.granted === false) return declined(textLang);
|
|
374
400
|
if (consent.granted === true && dialogConsent === 'denied') {
|
|
375
401
|
return { ok: false, reason: 'consent-declined', message: 'The talent skipped the AI-usage step in the dialog: nothing is scanned. Carry on without it. If they later ask for the scan themselves, call ai_usage without consent to ask them again.' };
|
|
@@ -488,9 +514,9 @@ function makeAiUsageTools(deps = {}) {
|
|
|
488
514
|
const usage = {
|
|
489
515
|
name: 'ai_usage',
|
|
490
516
|
title: 'Analyse how you work with AI',
|
|
491
|
-
description:
|
|
517
|
+
description: "Asks you first and, only if you say yes, looks at which AI tools you use on this computer, your conversations with AI assistants and the history of your code projects. It shows you exactly what would go to your Shakers profile (fragments of those conversations, sample commands and your agents' definitions, with anything that looks like a secret removed) and uploads it only if you approve it; an AI model from an external provider (OpenAI, Google or Anthropic) analyses that text for Shakers to compute your evaluation and then discards it, keeping only the result (levels, scores, counts and the names of your tools and agents).",
|
|
492
518
|
guidance:
|
|
493
|
-
"Measure how a Shakers talent uses AI in their work: scans this machine and repository for AI-tool usage (subagents, MCP servers, sessions, git activity) and scores their AI-maturity tier (T0-T7).
|
|
519
|
+
"Measure how a Shakers talent uses AI in their work: scans this machine and repository for AI-tool usage (subagents, MCP servers, sessions, git activity) and scores their AI-maturity tier (T0-T7). The scan runs on the talent's own machine; an approved upload sends derived signals plus secret-scrubbed text (conversation fragments, sample commands, agent definitions), which an AI model from an external provider (OpenAI, Google or Anthropic) analyses for Shakers and discards, keeping only the result. Before any scan, call it without consent: it asks the AI-usage question with both disclaimers in the same block (a dialog, or `say` to print word for word); only an explicit yes allows consent.granted:true, and skipping is consent.granted:false. The yes only scans: nothing is uploaded until the talent approves the review. Outside the sign-up, with a signed-in, email-verified session, the scan is followed by that review in the same call: a form when the client has one, otherwise `say` (what would be sent, what Shakers does with it, and the question) to print word for word, and then ai_usage_upload with the talent's answer. Without a verified session nothing can be sent and `text` is the report only. In the sign-up it is the step right after the account exists: on yes the scan runs in the background, then ai_usage_upload asks. After an upload, show relayVerbatim and `text` (the 'My work with AI' report, Markdown, no ANSI; keep the matrix grid fenced) verbatim; if next is ai_usage_result, call it in a few minutes. It runs no LLM on the machine. IDs and codes (agent `code`, skillId, catalogId) are internal handles for the add_* tools only — never show them.",
|
|
494
520
|
inputSchema: AI_USAGE_INPUT_SCHEMA,
|
|
495
521
|
handler,
|
|
496
522
|
};
|
|
@@ -500,7 +526,7 @@ function makeAiUsageTools(deps = {}) {
|
|
|
500
526
|
title: 'Review and upload your AI analysis',
|
|
501
527
|
description: 'Shows you exactly what your AI analysis would send to your Shakers profile and what Shakers does with it, lets you leave out repositories, agents or text excerpts, and uploads it only if you approve.',
|
|
502
528
|
guidance:
|
|
503
|
-
"The only way an AI-usage analysis reaches Shakers. Call it without confirm: it shows the review (a form the talent fills in when the client has one; otherwise
|
|
529
|
+
"The only way an AI-usage analysis reaches Shakers. Call it without confirm: it shows the review (a form the talent fills in when the client has one; otherwise a question to ask as its message says, plus `excludable`). In the chat, call it again with confirm:true only when the talent picked the upload option, with exclude for what they asked to leave out, or confirm:false when they picked not to; never decide for them, and confirm counts only after the review was shown in the chat. In the sign-up pass waitSeconds 30: reason scan-running means carry on and call it again after the interview step. On an upload print relayVerbatim word for word. Nothing is sent twice.",
|
|
504
530
|
inputSchema: UPLOAD_INPUT_SCHEMA,
|
|
505
531
|
handler: uploadHandler,
|
|
506
532
|
};
|
package/src/mcp-choice.js
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
const { signupCopy, legalCopy, termsText } = require('./signup-copy');
|
|
4
|
+
const { CHOICE_RULE, QUESTION_RULE, OPEN_RULE, nextSayRule } = require('./mcp-instructions');
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
// Closed questions of the MCP sign-up: a dialog when the client can elicit; otherwise a step whose text the model
|
|
7
|
+
// already wrote (handed over one answer earlier as `nextSay`) goes to the chat's question tool as `question` and
|
|
8
|
+
// `options`, and a step whose text only exists now hands it over whole as `say`, the question and its options numbered
|
|
9
|
+
// at its end, and the talent answers by typing.
|
|
10
|
+
|
|
11
|
+
const CHOICE_MESSAGE = CHOICE_RULE;
|
|
12
|
+
const QUESTION_ONLY_MESSAGE = `${QUESTION_RULE} Call no other tool until the talent answers, and pass back the option they pick (or what they type).`;
|
|
13
|
+
const OPEN_MESSAGE = OPEN_RULE;
|
|
6
14
|
|
|
7
15
|
// Fixed texts that are not a question ride on the next question block: a model skips a standalone relay.
|
|
8
16
|
const notices = [];
|
|
@@ -15,55 +23,136 @@ function takeNotices() {
|
|
|
15
23
|
return notices.splice(0);
|
|
16
24
|
}
|
|
17
25
|
|
|
18
|
-
//
|
|
19
|
-
|
|
20
|
-
|
|
26
|
+
// Texts handed to the model before the turn whose question they belong to: it writes them first in that turn, before
|
|
27
|
+
// any call, the only place a client shows them above its question tool. Each one keeps the question it was handed with
|
|
28
|
+
// (`from`) and the picks that lead to it, and counts as written only once the talent made one of those picks.
|
|
29
|
+
let handed = [];
|
|
30
|
+
|
|
31
|
+
function handOver(from, { step, ifAnswer, texts }) {
|
|
32
|
+
const text = texts.join('\n\n');
|
|
33
|
+
handed = [...handed.filter((h) => h.text !== text), { text, from, ifAnswer, picked: false }];
|
|
34
|
+
return { step, ifAnswer, text };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The talent answered `from` with `picked`: a text handed for that pick was written, one for another pick was not.
|
|
38
|
+
function settle(from, picked) {
|
|
39
|
+
handed = handed
|
|
40
|
+
.filter((h) => h.from !== from || h.ifAnswer.includes(picked))
|
|
41
|
+
.map((h) => (h.from === from ? { ...h, picked: true } : h));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Whether the model wrote this text at the start of the talent's turn.
|
|
45
|
+
function written(text) {
|
|
46
|
+
return handed.some((h) => h.picked && h.text === text);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// The next question the model asks closes the turn the handed texts were written in: they cover that question only.
|
|
50
|
+
function closeTurn() {
|
|
51
|
+
handed = [];
|
|
21
52
|
}
|
|
22
53
|
|
|
23
|
-
|
|
24
|
-
|
|
54
|
+
// A reply that hands over the next step's text, with what the model does with it; a list when two answers lead to two texts.
|
|
55
|
+
function withNextSay(reply, nextSay) {
|
|
56
|
+
if (!nextSay) return reply;
|
|
57
|
+
if (!Array.isArray(nextSay)) return { ...reply, nextSay, message: `${reply.message} ${nextSayRule(nextSay.ifAnswer)}` };
|
|
58
|
+
const rules = nextSay.map((n, i) => nextSayRule(n.ifAnswer, `nextSay[${i}].text`));
|
|
59
|
+
return { ...reply, nextSay, message: [reply.message, ...rules].join(' ') };
|
|
25
60
|
}
|
|
26
61
|
|
|
27
|
-
|
|
62
|
+
const numbered = (items) => items.map((item, i) => `${i + 1}. ${item}`).join('\n');
|
|
63
|
+
|
|
64
|
+
// What the talent has not read yet goes first, then the question and its options numbered; with `listed`, each option
|
|
65
|
+
// is numbered with its own block, the texts after the list follow it and the question comes last. A question whose
|
|
66
|
+
// texts the model already wrote goes alone to the question tool.
|
|
67
|
+
function chatChoice(question, options, texts = [], listed = null, queued = takeNotices()) {
|
|
68
|
+
const fresh = (blocks) => (blocks.length && written(blocks.join('\n\n')) ? [] : blocks);
|
|
69
|
+
const own = [...new Set([...fresh(queued), ...fresh(texts)])];
|
|
70
|
+
closeTurn();
|
|
71
|
+
if (!own.length && !listed) return { question, options, choiceMessage: QUESTION_ONLY_MESSAGE };
|
|
72
|
+
const body = listed ? [...listed.blocks.map((block, i) => `${i + 1}. ${block}`), ...listed.after, question] : [question, numbered(options)];
|
|
73
|
+
return { say: [...own, ...body].join('\n\n'), choiceMessage: CHOICE_MESSAGE };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// What the talent reads or picks from a chat question; its rule travels in the reply's `message`.
|
|
77
|
+
const forChat = ({ choiceMessage: _rule, ...chat }) => chat;
|
|
78
|
+
|
|
79
|
+
// An open question: the step's text ends with what the talent answers by typing, so it is all `say`.
|
|
80
|
+
function openQuestion(text) {
|
|
81
|
+
closeTurn();
|
|
82
|
+
return { say: [...takeNotices(), text].join('\n\n'), message: OPEN_MESSAGE };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const plain = (text) => text.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
|
|
86
|
+
const NUMBERED = /^(?:(?:opcion|opcao|opzione|option|numero|number|la|el|the)\s+)?(\d+)(?:\s+(.*))?$/;
|
|
87
|
+
|
|
88
|
+
// The option an answer names: its label, its number, or the talent's own words when they point at one option only:
|
|
89
|
+
// the start of its label, its whole label inside them, or its first word ("si", "no", "backend").
|
|
28
90
|
function optionFor(answer, options) {
|
|
29
91
|
if (typeof answer !== 'string') return null;
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
92
|
+
if (options.includes(answer.trim())) return answer.trim();
|
|
93
|
+
const said = plain(answer);
|
|
94
|
+
if (!said) return null;
|
|
95
|
+
const labels = options.map(plain);
|
|
96
|
+
const n = NUMBERED.exec(said);
|
|
97
|
+
if (n) {
|
|
98
|
+
const label = labels[Number(n[1]) - 1];
|
|
99
|
+
return label !== undefined && (!n[2] || label.startsWith(n[2])) ? options[Number(n[1]) - 1] : null;
|
|
100
|
+
}
|
|
101
|
+
const first = (text) => text.split(' ')[0];
|
|
102
|
+
const readings = [(l) => l === said, (l) => l.startsWith(`${said} `), (l) => ` ${said} `.includes(` ${l} `), (l) => first(l) === first(said)];
|
|
103
|
+
for (const reads of readings) {
|
|
104
|
+
const hits = options.filter((_, i) => reads(labels[i]));
|
|
105
|
+
if (hits.length) return hits.length === 1 ? hits[0] : null;
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function narrationOf(ctx) {
|
|
111
|
+
return ctx && typeof ctx.narration === 'string' && ctx.narration ? [ctx.narration] : [];
|
|
34
112
|
}
|
|
35
113
|
|
|
36
114
|
// { answer } from the dialog, { dismissed: <action> } when the talent closed it, or { chat } when there is no dialog.
|
|
37
|
-
async function askChoice(ctx, { texts: own = [], question, options }) {
|
|
38
|
-
const
|
|
115
|
+
async function askChoice(ctx, { texts: own = [], question, options, listed = null }) {
|
|
116
|
+
const queued = takeNotices();
|
|
39
117
|
const elicit = ctx && typeof ctx.elicit === 'function' ? ctx.elicit : null;
|
|
40
118
|
const reply = elicit
|
|
41
119
|
? await elicit({
|
|
42
|
-
message: [...
|
|
120
|
+
message: [...narrationOf(ctx), ...queued, ...own, ...(listed ? [...listed.blocks, ...listed.after] : []), question].join('\n\n'),
|
|
43
121
|
requestedSchema: { type: 'object', properties: { choice: { type: 'string', title: question, enum: options } }, required: ['choice'] },
|
|
44
122
|
})
|
|
45
123
|
: null;
|
|
46
|
-
if (!reply) return { chat: chatChoice(question, options,
|
|
124
|
+
if (!reply) return { chat: chatChoice(question, options, own, listed, queued) };
|
|
47
125
|
const choice = reply.action === 'accept' && reply.content ? reply.content.choice : undefined;
|
|
48
126
|
if (options.includes(choice)) return { answer: choice };
|
|
49
|
-
return { dismissed: reply.action, chat: chatChoice(question, options,
|
|
127
|
+
return { dismissed: reply.action, chat: chatChoice(question, options, own, listed, queued) };
|
|
50
128
|
}
|
|
51
129
|
|
|
130
|
+
// The steps whose text went to the chat, keyed by what their answer decides (`about`, the texts by default): an answer
|
|
131
|
+
// counts only after its text was handed over, so the model cannot confirm a draft the talent never read.
|
|
132
|
+
const shownTexts = new Set();
|
|
133
|
+
|
|
52
134
|
// A closed question after fixed texts: the chat answer the model passed back, else the dialog, else the block to print.
|
|
53
|
-
async function askAfterTexts(ctx, { tool, texts, question, options, answer }) {
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
const
|
|
135
|
+
async function askAfterTexts(ctx, { tool, texts, question, options, answer, listed = null, about = texts, nextSay = null }) {
|
|
136
|
+
const withText = texts.length > 0 || !!listed;
|
|
137
|
+
const key = JSON.stringify([tool, question, options, about]);
|
|
138
|
+
const picked = withText && !shownTexts.has(key) ? null : optionFor(answer, options);
|
|
139
|
+
if (picked) {
|
|
140
|
+
shownTexts.delete(key);
|
|
141
|
+
settle(key, picked);
|
|
142
|
+
return { answer: picked };
|
|
143
|
+
}
|
|
144
|
+
const asked = ctx && ctx.elicitation ? await askChoice(ctx, { texts, question, options, listed }) : { chat: chatChoice(question, options, texts, listed) };
|
|
57
145
|
if (asked.answer) return { answer: asked.answer };
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
},
|
|
146
|
+
if (withText) shownTexts.add(key);
|
|
147
|
+
const reply = {
|
|
148
|
+
ok: false,
|
|
149
|
+
reason: 'answer-required',
|
|
150
|
+
...(asked.dismissed ? { dismissed: asked.dismissed } : {}),
|
|
151
|
+
...forChat(asked.chat),
|
|
152
|
+
message: `${asked.chat.choiceMessage} Then call ${tool} again with answer = what the talent answers: the number, the option or their own words.`,
|
|
66
153
|
};
|
|
154
|
+
const handed = Array.isArray(nextSay) ? nextSay.map((n) => handOver(key, n)) : nextSay && handOver(key, nextSay);
|
|
155
|
+
return { reply: nextSay ? withNextSay(reply, handed) : reply };
|
|
67
156
|
}
|
|
68
157
|
|
|
69
158
|
// Questions this process put to the talent in the chat, keyed by exactly what they would allow:
|
|
@@ -71,14 +160,14 @@ async function askAfterTexts(ctx, { tool, texts, question, options, answer }) {
|
|
|
71
160
|
const pendingConsent = new Set();
|
|
72
161
|
|
|
73
162
|
// The talent's yes or no before a tool reads this computer or sends data: { granted } or { reply } to return.
|
|
74
|
-
async function askConsent(ctx, { tool, key, question, yes, no, answer }) {
|
|
163
|
+
async function askConsent(ctx, { tool, key, texts = [], question, yes, no, answer, field = 'answer' }) {
|
|
75
164
|
const options = [yes, no];
|
|
76
165
|
const picked = pendingConsent.has(key) ? optionFor(answer, options) : null;
|
|
77
166
|
if (picked) {
|
|
78
167
|
pendingConsent.delete(key);
|
|
79
168
|
return { granted: picked === yes };
|
|
80
169
|
}
|
|
81
|
-
const asked = ctx && ctx.elicitation ? await askChoice(ctx, { question, options }) : { chat: chatChoice(question, options) };
|
|
170
|
+
const asked = ctx && ctx.elicitation ? await askChoice(ctx, { texts, question, options }) : { chat: chatChoice(question, options, texts) };
|
|
82
171
|
if (asked.answer) return { granted: asked.answer === yes };
|
|
83
172
|
pendingConsent.add(key);
|
|
84
173
|
return {
|
|
@@ -86,10 +175,58 @@ async function askConsent(ctx, { tool, key, question, yes, no, answer }) {
|
|
|
86
175
|
ok: false,
|
|
87
176
|
reason: 'consent-required',
|
|
88
177
|
...(asked.dismissed ? { dismissed: asked.dismissed } : {}),
|
|
89
|
-
...asked.chat,
|
|
90
|
-
message: `Nothing was read or sent. ${asked.chat.choiceMessage} Then call ${tool} again with the same arguments and
|
|
178
|
+
...forChat(asked.chat),
|
|
179
|
+
message: `Nothing was read or sent. ${asked.chat.choiceMessage} Then call ${tool} again with the same arguments and ${field} = what the talent answers: the number, the option or their own words.`,
|
|
91
180
|
},
|
|
92
181
|
};
|
|
93
182
|
}
|
|
94
183
|
|
|
95
|
-
|
|
184
|
+
const termsAccepted = new Set();
|
|
185
|
+
|
|
186
|
+
// The terms block the terms question carries, with its links: the sign-up hands the same text over one answer early.
|
|
187
|
+
function registrationTermsText(lang) {
|
|
188
|
+
return termsText(lang, (label, url) => `[${label}](${url})`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function askRegistrationTerms(ctx, { tool, lang, termsAnswer, newsletterAnswer }) {
|
|
192
|
+
const c = signupCopy(lang);
|
|
193
|
+
if (!termsAccepted.has(tool)) {
|
|
194
|
+
const terms = await askConsent(ctx, {
|
|
195
|
+
tool,
|
|
196
|
+
key: `${tool}:terms`,
|
|
197
|
+
field: 'termsAnswer',
|
|
198
|
+
texts: [registrationTermsText(lang)],
|
|
199
|
+
question: c.termsQuestion,
|
|
200
|
+
yes: c.termsAccept,
|
|
201
|
+
no: c.termsDecline,
|
|
202
|
+
answer: termsAnswer,
|
|
203
|
+
});
|
|
204
|
+
if (terms.reply) return { reply: terms.reply };
|
|
205
|
+
if (!terms.granted) return { declined: true };
|
|
206
|
+
termsAccepted.add(tool);
|
|
207
|
+
}
|
|
208
|
+
const newsletter = legalCopy(lang).newsletter;
|
|
209
|
+
const news = await askConsent(ctx, {
|
|
210
|
+
tool,
|
|
211
|
+
key: `${tool}:newsletter`,
|
|
212
|
+
field: 'newsletterAnswer',
|
|
213
|
+
question: c.newsletterQuestion,
|
|
214
|
+
yes: newsletter,
|
|
215
|
+
no: c.newsletterNo,
|
|
216
|
+
answer: newsletterAnswer,
|
|
217
|
+
});
|
|
218
|
+
if (news.reply) return { reply: news.reply };
|
|
219
|
+
termsAccepted.delete(tool);
|
|
220
|
+
return { newsletterConsent: news.granted };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// A new sign-up in the same process starts with nothing queued, shown or asked.
|
|
224
|
+
function resetQuestions() {
|
|
225
|
+
takeNotices();
|
|
226
|
+
handed = [];
|
|
227
|
+
shownTexts.clear();
|
|
228
|
+
pendingConsent.clear();
|
|
229
|
+
termsAccepted.clear();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
module.exports = { askConsent, askChoice, askAfterTexts, askRegistrationTerms, registrationTermsText, narrationOf, chatChoice, forChat, openQuestion, optionFor, queueNotice, takeNotices, handOver, settle, withNextSay, written, resetQuestions, CHOICE_RULE, CHOICE_MESSAGE, QUESTION_ONLY_MESSAGE, OPEN_MESSAGE };
|
package/src/mcp-detector.js
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
|
-
const { getHomeDir } = require('./env-paths');
|
|
5
|
+
const { getHomeDir, codexHome, piAgentDir } = require('./env-paths');
|
|
6
|
+
const { OWN_MCP_SERVER_NAMES } = require('./mcp-server-name');
|
|
6
7
|
|
|
7
8
|
// KEYWORD -> { SERVICE LABEL, CATEGORY }.
|
|
8
9
|
const SERVICE_CATALOG = [
|
|
@@ -100,6 +101,11 @@ const MCP_CONFIG_LOCATIONS = [
|
|
|
100
101
|
// --- Gemini CLI: home was read; the project-level settings file was not ---
|
|
101
102
|
{ tool: 'gemini-cli', scope: 'home', keys: ['mcpServers'], confidence: 'medium', file: (root, home) => path.join(home, '.gemini', 'settings.json') },
|
|
102
103
|
{ tool: 'gemini-cli', scope: 'project', keys: ['mcpServers'], confidence: 'medium', file: (root) => path.join(root, '.gemini', 'settings.json') },
|
|
104
|
+
{ tool: 'codex-cli', scope: 'home', read: readTomlMcpServerNames, confidence: 'high', file: () => path.join(codexHome(), 'config.toml') },
|
|
105
|
+
{ tool: 'pi', scope: 'home', keys: ['mcpServers'], confidence: 'high', file: () => path.join(piAgentDir(), 'mcp.json') },
|
|
106
|
+
{ tool: 'pi', scope: 'project', keys: ['mcpServers'], confidence: 'high', file: (root) => path.join(root, '.pi', 'mcp.json') },
|
|
107
|
+
{ tool: 'opencode', scope: 'home', keys: ['mcp'], confidence: 'medium', file: (root, home) => path.join(home, '.config', 'opencode', 'opencode.json') },
|
|
108
|
+
{ tool: 'opencode', scope: 'project', keys: ['mcp'], confidence: 'medium', file: (root) => path.join(root, 'opencode.json') },
|
|
103
109
|
// VS Code: `servers`, not `mcpServers`, and three OS-specific user paths.
|
|
104
110
|
{ tool: null, scope: 'project', keys: ['servers', 'mcpServers'], confidence: 'medium', file: (root) => path.join(root, '.vscode', 'mcp.json') },
|
|
105
111
|
{ tool: null, scope: 'home', keys: ['servers', 'mcpServers'], confidence: 'medium', file: (root, home) => path.join(home, 'Library', 'Application Support', 'Code', 'User', 'mcp.json') },
|
|
@@ -134,6 +140,23 @@ function readMcpServerNames(file, keys = ['mcpServers']) {
|
|
|
134
140
|
return [];
|
|
135
141
|
}
|
|
136
142
|
|
|
143
|
+
const TOML_MCP_TABLE = /^\s*\[\s*mcp_servers\.(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9_-]+))/;
|
|
144
|
+
|
|
145
|
+
function readTomlMcpServerNames(file) {
|
|
146
|
+
let raw;
|
|
147
|
+
try {
|
|
148
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
149
|
+
} catch {
|
|
150
|
+
return [];
|
|
151
|
+
}
|
|
152
|
+
const names = new Set();
|
|
153
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
154
|
+
const m = TOML_MCP_TABLE.exec(line);
|
|
155
|
+
if (m) names.add(m[1] || m[2] || m[3]);
|
|
156
|
+
}
|
|
157
|
+
return [...names];
|
|
158
|
+
}
|
|
159
|
+
|
|
137
160
|
// Deterministic (no-LLM) MCP-by-name detection over every location in `MCP_CONFIG_LOCATIONS` (project ∪ home).
|
|
138
161
|
function detectMcpServers(root) {
|
|
139
162
|
const home = getHomeDir();
|
|
@@ -148,7 +171,7 @@ function detectMcpServers(root) {
|
|
|
148
171
|
} catch {
|
|
149
172
|
continue; // an unresolvable home never breaks the other locations
|
|
150
173
|
}
|
|
151
|
-
const names = readMcpServerNames(file, loc.keys);
|
|
174
|
+
const names = (loc.read ? loc.read(file) : readMcpServerNames(file, loc.keys)).filter((n) => !OWN_MCP_SERVER_NAMES.has(n));
|
|
152
175
|
if (loc.tool && names.length) {
|
|
153
176
|
// Per-tool count is the union across THAT tool's own locations, so a
|
|
154
177
|
// server configured both in the project and globally counts once for it.
|
|
@@ -220,4 +243,5 @@ module.exports = {
|
|
|
220
243
|
serviceForMcpServerName,
|
|
221
244
|
MCP_CONFIG_LOCATIONS,
|
|
222
245
|
SERVICE_CATALOG,
|
|
246
|
+
readTomlMcpServerNames,
|
|
223
247
|
};
|