shakers-cli 0.0.0-stage → 0.14.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/LICENSE +59 -0
- package/README.md +1233 -2
- package/bin/add-agent.js +88 -0
- package/bin/add-project.js +88 -0
- package/bin/add-role.js +61 -0
- package/bin/add-skill.js +88 -0
- package/bin/ai-usage.js +99 -0
- package/bin/alma.js +99 -0
- package/bin/applications.js +69 -0
- package/bin/availability.js +70 -0
- package/bin/certifications.js +69 -0
- package/bin/certify.js +73 -0
- package/bin/change-role.js +61 -0
- package/bin/config.js +124 -0
- package/bin/experiences.js +69 -0
- package/bin/find-projects.js +89 -0
- package/bin/invitations.js +69 -0
- package/bin/lang.js +70 -0
- package/bin/login.js +216 -0
- package/bin/mcp.js +124 -0
- package/bin/mcpb.js +5 -0
- package/bin/me.js +69 -0
- package/bin/onboarding.js +134 -0
- package/bin/portfolios.js +69 -0
- package/bin/rate.js +70 -0
- package/bin/register.js +337 -0
- package/bin/report-html.js +134 -0
- package/bin/save.js +77 -0
- package/bin/shakers.js +363 -0
- package/bin/share.js +72 -0
- package/bin/show-project.js +72 -0
- package/bin/socials.js +70 -0
- package/bin/superadmin.js +351 -0
- package/bin/update-check-worker.js +70 -0
- package/bin/update.js +111 -0
- package/package.json +40 -4
- package/src/agent-category.js +13 -0
- package/src/agent-evaluation.js +177 -0
- package/src/agent-org-chart.js +355 -0
- package/src/agent-synthesis.js +132 -0
- package/src/agent-usage.js +118 -0
- package/src/agents-client.js +165 -0
- package/src/ai-fluency-cell.js +72 -0
- package/src/ai-profile-client.js +45 -0
- package/src/ai-profile-preview.js +154 -0
- package/src/ai.js +66 -0
- package/src/alma-client.js +123 -0
- package/src/alma-flow.js +32 -0
- package/src/alma-repo-context.js +47 -0
- package/src/alma-shell.js +161 -0
- package/src/ansi.js +62 -0
- package/src/applications-client.js +79 -0
- package/src/applications-flow.js +58 -0
- package/src/auth-client.js +144 -0
- package/src/auth-device-client.js +118 -0
- package/src/auth-method-picker.js +44 -0
- package/src/auth-methods.js +35 -0
- package/src/auth-session-store.js +215 -0
- package/src/authorship-mismatch.js +62 -0
- package/src/authorship.js +204 -0
- package/src/automations-detector.js +170 -0
- package/src/availability-client.js +93 -0
- package/src/availability-flow.js +124 -0
- package/src/backend-request.js +191 -0
- package/src/blind-approval-extract.js +48 -0
- package/src/brand-ansi.js +14 -0
- package/src/brand-svg.js +9 -0
- package/src/browser-tools-detector.js +21 -0
- package/src/build-next-level.js +56 -0
- package/src/certifications-client.js +89 -0
- package/src/certifications-flow.js +75 -0
- package/src/certify-dimension-client.js +163 -0
- package/src/certify-dimension-flow.js +135 -0
- package/src/certify-disclaimer.js +51 -0
- package/src/certify-remediation-prompt.js +23 -0
- package/src/cli-args.js +83 -0
- package/src/command-audit.js +105 -0
- package/src/config-dir.js +120 -0
- package/src/config.js +877 -0
- package/src/consent-flow.js +115 -0
- package/src/consent-skip.js +27 -0
- package/src/cv-file.js +62 -0
- package/src/cv-text.js +92 -0
- package/src/decisions-extract.js +43 -0
- package/src/detect-repos.js +145 -0
- package/src/detected-tools.js +15 -0
- package/src/detection-evidence.js +105 -0
- package/src/detection-noise.js +132 -0
- package/src/detectors.js +221 -0
- package/src/device-auth-store.js +100 -0
- package/src/device-login.js +120 -0
- package/src/email-verification.js +152 -0
- package/src/env-paths.js +30 -0
- package/src/find-projects-cache.js +89 -0
- package/src/find-projects-client.js +148 -0
- package/src/find-projects-flow.js +164 -0
- package/src/git-activity.js +124 -0
- package/src/git-log-prefetch.js +53 -0
- package/src/graph-certs.js +58 -0
- package/src/graph-generator.js +230 -0
- package/src/graph-scan.js +239 -0
- package/src/i18n.js +3341 -0
- package/src/inter-fonts.js +6 -0
- package/src/interactive-select.js +183 -0
- package/src/interview-client.js +226 -0
- package/src/interview-transcript.js +56 -0
- package/src/inventory-client.js +90 -0
- package/src/invitations-client.js +66 -0
- package/src/invitations-flow.js +50 -0
- package/src/lang-client.js +147 -0
- package/src/lang-codes.js +14 -0
- package/src/lang-flow.js +85 -0
- package/src/legal-notice.js +53 -0
- package/src/locale.js +72 -0
- package/src/markdown-ansi.js +50 -0
- package/src/maturity.js +111 -0
- package/src/mcp-ai-usage-result-tool.js +111 -0
- package/src/mcp-ai-usage-tool.js +401 -0
- package/src/mcp-alma-tools.js +95 -0
- package/src/mcp-applications-tools.js +44 -0
- package/src/mcp-auth-form.js +480 -0
- package/src/mcp-availability-tools.js +45 -0
- package/src/mcp-certifications-tools.js +44 -0
- package/src/mcp-certify-dimension-tools.js +66 -0
- package/src/mcp-choice.js +95 -0
- package/src/mcp-detector.js +223 -0
- package/src/mcp-device-login-tools.js +177 -0
- package/src/mcp-find-projects-tools.js +69 -0
- package/src/mcp-install.js +275 -0
- package/src/mcp-instructions.js +27 -0
- package/src/mcp-invitations-tools.js +44 -0
- package/src/mcp-lang-tools.js +41 -0
- package/src/mcp-onboarding-tools.js +379 -0
- package/src/mcp-portfolios-tools.js +50 -0
- package/src/mcp-profile-tools.js +48 -0
- package/src/mcp-prompts.js +73 -0
- package/src/mcp-rate-tools.js +44 -0
- package/src/mcp-register-tools.js +441 -0
- package/src/mcp-report-tools.js +105 -0
- package/src/mcp-role-tools.js +372 -0
- package/src/mcp-saved-positions-tools.js +59 -0
- package/src/mcp-server.js +290 -0
- package/src/mcp-show-project-tools.js +52 -0
- package/src/mcp-signup-client.js +118 -0
- package/src/mcp-signup-tools.js +1171 -0
- package/src/mcp-socials-tools.js +41 -0
- package/src/memory-structure-detector.js +108 -0
- package/src/model-call-record.js +198 -0
- package/src/onboarding-availability.js +26 -0
- package/src/onboarding-client.js +374 -0
- package/src/onboarding-flow.js +1162 -0
- package/src/onboarding-funnel.js +272 -0
- package/src/onboarding-livekit-client.js +102 -0
- package/src/onboarding-status-client.js +57 -0
- package/src/open-file.js +32 -0
- package/src/osc-link.js +16 -0
- package/src/portfolio-client.js +164 -0
- package/src/portfolio-git-info.js +61 -0
- package/src/portfolios-client.js +85 -0
- package/src/portfolios-flow.js +79 -0
- package/src/profile-client.js +133 -0
- package/src/profile-flow.js +115 -0
- package/src/profile-switch.js +46 -0
- package/src/prompt-select.js +66 -0
- package/src/rate-client.js +81 -0
- package/src/rate-flow.js +137 -0
- package/src/register-context.js +145 -0
- package/src/render-ai-fluency.js +266 -0
- package/src/render-certification.js +177 -0
- package/src/render-html.js +140 -0
- package/src/render-report-text.js +27 -0
- package/src/render-sheet.js +261 -0
- package/src/render-terminal.js +109 -0
- package/src/render-usage-report-text.js +155 -0
- package/src/repo-context.js +215 -0
- package/src/report-gating.js +49 -0
- package/src/report-store.js +226 -0
- package/src/report-theme.js +177 -0
- package/src/report-view-client.js +50 -0
- package/src/reveal-output.js +29 -0
- package/src/roadmap-content.js +453 -0
- package/src/roadmap-personalization.js +132 -0
- package/src/roadmap-prompt.js +154 -0
- package/src/roles-client.js +112 -0
- package/src/roles-flow.js +192 -0
- package/src/sanitize-network-text.js +36 -0
- package/src/saved-positions-client.js +71 -0
- package/src/saved-positions-flow.js +50 -0
- package/src/scan-exclusions.js +50 -0
- package/src/scanner.js +624 -0
- package/src/scope-signals.js +148 -0
- package/src/secret-prompt.js +72 -0
- package/src/session-gate.js +30 -0
- package/src/session-normalizers.js +174 -0
- package/src/session-refresh.js +51 -0
- package/src/session-scan.js +475 -0
- package/src/session-signals.js +36 -0
- package/src/shakers-wordmark.js +17 -0
- package/src/share-card.js +362 -0
- package/src/share-certification.js +111 -0
- package/src/share-consent-state.js +157 -0
- package/src/share-payload.js +300 -0
- package/src/share.js +185 -0
- package/src/show-project-client.js +58 -0
- package/src/show-project-flow.js +119 -0
- package/src/signup-client.js +103 -0
- package/src/signup-copy.js +450 -0
- package/src/signup-language.js +21 -0
- package/src/skills-client.js +102 -0
- package/src/socials-client.js +91 -0
- package/src/socials-flow.js +83 -0
- package/src/specificity-extract.js +41 -0
- package/src/start-add-agents.js +306 -0
- package/src/start-add-portfolio.js +388 -0
- package/src/start-add-skills.js +260 -0
- package/src/stdin-ask.js +129 -0
- package/src/steering-extract.js +43 -0
- package/src/strip-internal-comments.js +45 -0
- package/src/tech-detector.js +295 -0
- package/src/templates/report-sheet.html +572 -0
- package/src/terminal-agents.js +207 -0
- package/src/terminal-format.js +85 -0
- package/src/terminal-progress.js +106 -0
- package/src/terminal-sections.js +219 -0
- package/src/tier-analysis.js +126 -0
- package/src/tier-engine.js +114 -0
- package/src/tier-projection.js +117 -0
- package/src/transcript-turns.js +49 -0
- package/src/translations-client.js +84 -0
- package/src/typewriter.js +154 -0
- package/src/update-flow.js +223 -0
- package/src/update-notifier.js +113 -0
- package/src/usage-model-calls.js +119 -0
- package/src/usage-oneshot-commands.js +180 -0
- package/src/usage-repo-scope.js +162 -0
- package/src/usage-run-steps.js +354 -0
- package/src/work-streams.js +79 -0
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { TranscriptBuffer } = require('./interview-transcript');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { askChoice, askConsent } = require('./mcp-choice');
|
|
6
|
+
const { expandHome, tildify } = require('./env-paths');
|
|
7
|
+
|
|
8
|
+
const ANSWER_MODES = ['drafted', 'own', 'full'];
|
|
9
|
+
|
|
10
|
+
const INTERVIEW_START_SCHEMA = {
|
|
11
|
+
type: 'object',
|
|
12
|
+
properties: {
|
|
13
|
+
candidateId: { type: 'string', description: "Talent UUID (the JWT sub). Optional — defaults to the signed-in talent's UUID derived from the session. An email is NOT accepted by the server." },
|
|
14
|
+
language: { type: 'string', enum: ['es', 'en'] },
|
|
15
|
+
disclaimerAcknowledged: {
|
|
16
|
+
type: 'boolean',
|
|
17
|
+
description: 'true only when the talent answered the answer-mode question this tool printed with both interview disclaimers. Call it first without it: it asks here-or-later and then that question.',
|
|
18
|
+
},
|
|
19
|
+
where: {
|
|
20
|
+
type: 'string',
|
|
21
|
+
enum: ['here', 'later'],
|
|
22
|
+
description: "The talent's pick to 'here now or later on the web?'. Omit it to have the tool ask: in a dialog when the client has one, otherwise it returns the question with its options.",
|
|
23
|
+
},
|
|
24
|
+
answerMode: {
|
|
25
|
+
type: 'string',
|
|
26
|
+
enum: ANSWER_MODES,
|
|
27
|
+
description: "The talent's pick to the answer-mode question: drafted = you propose each answer and they approve or edit it, own = they answer themselves, full = you answer every question yourself. Required with disclaimerAcknowledged:true; omit it to have the tool ask, in a dialog or as options it returns.",
|
|
28
|
+
},
|
|
29
|
+
confirmRepeat: {
|
|
30
|
+
type: 'boolean',
|
|
31
|
+
description: 'Only needed when the talent has ALREADY completed onboarding. The tool then refuses with an already-completed notice unless you set this true after the talent explicitly confirms they want to repeat the interview.',
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const INTERVIEW_TURN_SCHEMA = {
|
|
37
|
+
type: 'object',
|
|
38
|
+
properties: {
|
|
39
|
+
interviewId: { type: 'string', description: 'Internal handle for this interview — for calling the interview tools ONLY; never shown or mentioned to the talent.' },
|
|
40
|
+
message: { type: 'string', description: "The talent's answer to the current question: in 'own' mode their REAL, VERBATIM words; in 'drafted' mode the answer they APPROVED or edited after seeing your proposal; in 'full' mode (or once they explicitly asked you to answer the rest yourself) your own answer built only from what they confirmed. Outside 'full' mode never a value they have not seen and confirmed: if they have not answered or approved yet, do NOT call this." },
|
|
41
|
+
},
|
|
42
|
+
required: ['interviewId', 'message'],
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const INTERVIEW_COMPLETE_SCHEMA = {
|
|
46
|
+
type: 'object',
|
|
47
|
+
properties: { interviewId: { type: 'string' } },
|
|
48
|
+
required: ['interviewId'],
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const FINISH_SCHEMA = { type: 'object', properties: {} };
|
|
52
|
+
|
|
53
|
+
const LIVEKIT_LOAD_FAILURES = new Set(['livekit-not-installed', 'livekit-no-text-streams']);
|
|
54
|
+
// A start or a turn that has not answered by then ends here, and the talent does the interview on the web.
|
|
55
|
+
const INTERVIEW_TIMEOUT_MS = 120000;
|
|
56
|
+
const TIMED_OUT = Symbol('timed-out');
|
|
57
|
+
const TIMEOUT_KINDS = new Set(['timeout', 'turn-timeout']);
|
|
58
|
+
// The chat interview runs only in the languages the interview agent speaks; the others do it on the web.
|
|
59
|
+
const CHAT_INTERVIEW_LANGUAGES = new Set(['es', 'en']);
|
|
60
|
+
|
|
61
|
+
function withTimeout(promise, ms) {
|
|
62
|
+
let timer;
|
|
63
|
+
const expiry = new Promise((resolve) => { timer = setTimeout(resolve, ms, TIMED_OUT); });
|
|
64
|
+
return Promise.race([promise, expiry]).finally(() => clearTimeout(timer));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Stays local (stderr, the MCP client's log); only the sign-up funnel is sent.
|
|
68
|
+
function defaultReportLivekitFailure(kind, error) {
|
|
69
|
+
const cause = error && error.cause && error.cause.message ? error.cause.message : (error && error.message) || '';
|
|
70
|
+
process.stderr.write(` [mcp] livekit-unavailable kind=${kind} os=${process.platform} arch=${process.arch} node=${process.version} cause=${JSON.stringify(cause.slice(0, 300))}\n`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// The last interview this process completed: the main-role step waits for its evaluation.
|
|
74
|
+
let lastCompletedInterview = null;
|
|
75
|
+
|
|
76
|
+
function completedOnboardingInterview() {
|
|
77
|
+
return lastCompletedInterview;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function makeRegisterTools(deps = {}) {
|
|
81
|
+
const flow = require('./onboarding-flow');
|
|
82
|
+
const resolved = flow.makeDeps(deps.flowDeps || {});
|
|
83
|
+
const lang = deps.lang || 'en';
|
|
84
|
+
// Same confirmation copy the CLI prints ("Price saved.", …), reused verbatim.
|
|
85
|
+
const catalog = require('./i18n').getCatalog(lang).onboarding;
|
|
86
|
+
// The talent's sign-up language, for what this tool asks them in the chat.
|
|
87
|
+
const textLang = () => require('./signup-language').signupLanguage(lang);
|
|
88
|
+
const copy = () => require('./signup-copy').signupCopy(textLang());
|
|
89
|
+
const checkCompleted = deps.checkOnboardingCompleted || require('./onboarding-availability').checkOnboardingCompleted;
|
|
90
|
+
const funnel = deps.funnel || require('./onboarding-funnel').NOOP_FUNNEL;
|
|
91
|
+
const track = (record) => { record(); funnel.flush(); };
|
|
92
|
+
|
|
93
|
+
// The LiveKit room is a live WebRTC connection that must survive between the
|
|
94
|
+
// separate start/turn/complete JSON-RPC calls of this long-lived stdio server.
|
|
95
|
+
const interviewSessions = new Map();
|
|
96
|
+
|
|
97
|
+
const reportLivekitFailure = deps.reportLivekitFailure || defaultReportLivekitFailure;
|
|
98
|
+
const interviewTimeoutMs = deps.interviewTimeoutMs || INTERVIEW_TIMEOUT_MS;
|
|
99
|
+
|
|
100
|
+
function onWeb(step, reason) {
|
|
101
|
+
return { ok: false, step, reason, relayVerbatim: copy().livekitUnavailable, message: 'Print relayVerbatim word for word now, then call open_web: the talent does the interview on the web.', next: 'open_web' };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function teardownInterview(interviewId, { persist = false } = {}) {
|
|
105
|
+
const sess = interviewSessions.get(interviewId);
|
|
106
|
+
if (!sess) return { ok: true };
|
|
107
|
+
interviewSessions.delete(interviewId);
|
|
108
|
+
let saved = { ok: true };
|
|
109
|
+
if (persist && sess.buffer.length > 0) {
|
|
110
|
+
const durationSeconds = Math.max(0, Math.floor((Date.now() - sess.connectAt) / 1000));
|
|
111
|
+
saved = await flow.completeLivekitOnboarding(resolved, {
|
|
112
|
+
interviewId,
|
|
113
|
+
durationSeconds,
|
|
114
|
+
transcripts: sess.buffer.toTranscripts(),
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
try { await sess.client.disconnect(); } catch { /* room already gone */ }
|
|
118
|
+
return saved;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function interviewLater() {
|
|
122
|
+
track(() => funnel.completed('pre_interview', { interview_choice: 'skip' }));
|
|
123
|
+
return { ok: false, reason: 'interview-later', message: 'The talent does the interview later on the web: skip it here and go on with the main role, then open_web.' };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function askWhere(args, ctx) {
|
|
127
|
+
if (args.where === 'here') return { here: true };
|
|
128
|
+
if (args.where === 'later') return { here: false };
|
|
129
|
+
const c = copy();
|
|
130
|
+
const asked = await askChoice(ctx, { question: c.interviewWhereQuestion, options: [c.interviewHere, c.interviewLater] });
|
|
131
|
+
if (asked.answer) return { here: asked.answer === c.interviewHere };
|
|
132
|
+
return { chat: asked.chat, dismissed: asked.dismissed };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function askAnswerMode(args, ctx, texts) {
|
|
136
|
+
if (ANSWER_MODES.includes(args.answerMode)) return { mode: args.answerMode };
|
|
137
|
+
const c = copy();
|
|
138
|
+
const labels = { drafted: c.answerModeDrafted, own: c.answerModeOwn, full: c.answerModeFull };
|
|
139
|
+
const asked = await askChoice(ctx, { texts, question: c.answerModeQuestion, options: ANSWER_MODES.map((m) => labels[m]) });
|
|
140
|
+
if (asked.answer) return { mode: ANSWER_MODES.find((m) => labels[m] === asked.answer) };
|
|
141
|
+
return { chat: asked.chat, dismissed: asked.dismissed };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function interviewStart(args = {}, ctx = {}) {
|
|
145
|
+
let alreadyCompleted = false;
|
|
146
|
+
try {
|
|
147
|
+
alreadyCompleted = await checkCompleted({});
|
|
148
|
+
} catch {
|
|
149
|
+
alreadyCompleted = false;
|
|
150
|
+
}
|
|
151
|
+
if (alreadyCompleted && args.confirmRepeat !== true) {
|
|
152
|
+
return {
|
|
153
|
+
ok: false,
|
|
154
|
+
reason: 'onboarding-already-completed',
|
|
155
|
+
alreadyCompleted: true,
|
|
156
|
+
message:
|
|
157
|
+
'The talent already completed the onboarding interview: skip it and say nothing about it to the talent, just go on with the next step. Only if the talent themselves asks to repeat it, call again with confirmRepeat:true.',
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
if (!CHAT_INTERVIEW_LANGUAGES.has(textLang())) {
|
|
161
|
+
return { ok: false, reason: 'interview-on-web', relayVerbatim: copy().interviewOnWeb, message: 'Print relayVerbatim word for word now. The interview is not offered here in this language: skip it, go on with the main role, then open_web.' };
|
|
162
|
+
}
|
|
163
|
+
const acknowledged = args.disclaimerAcknowledged === true;
|
|
164
|
+
if (!acknowledged) {
|
|
165
|
+
track(() => funnel.viewed('pre_interview'));
|
|
166
|
+
const where = await askWhere(args, ctx);
|
|
167
|
+
if (where.here === false) return interviewLater();
|
|
168
|
+
if (where.chat) {
|
|
169
|
+
return {
|
|
170
|
+
ok: false,
|
|
171
|
+
reason: 'where-required',
|
|
172
|
+
...(where.dismissed ? { dismissed: where.dismissed } : {}),
|
|
173
|
+
...where.chat,
|
|
174
|
+
message: `${where.chat.choiceMessage} Then call onboarding_interview_start again with where:"here" for the first option or where:"later" for the second.`,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// The two disclaimers travel with the answer-mode question, so they are read before the talent answers it.
|
|
179
|
+
const disclaimers = acknowledged ? [] : Object.values(flow.interviewDisclaimers(textLang()));
|
|
180
|
+
const answer = await askAnswerMode(acknowledged ? args : {}, ctx, disclaimers);
|
|
181
|
+
if (answer.chat) {
|
|
182
|
+
return {
|
|
183
|
+
ok: false,
|
|
184
|
+
reason: 'answer-mode-required',
|
|
185
|
+
...(answer.dismissed ? { dismissed: answer.dismissed } : {}),
|
|
186
|
+
...answer.chat,
|
|
187
|
+
message: `${answer.chat.choiceMessage} Then call onboarding_interview_start again with where:"here", disclaimerAcknowledged:true and answerMode: drafted for the first option, own for the second, full for the third.`,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
track(() => funnel.completed('pre_interview', { interview_choice: 'start_now' }));
|
|
191
|
+
const room = {};
|
|
192
|
+
const opened = await withTimeout(openRoom(args, room), interviewTimeoutMs);
|
|
193
|
+
if (opened === TIMED_OUT || !opened.ok) track(() => funnel.errored('interview', opened === TIMED_OUT ? 'timeout' : opened.reason));
|
|
194
|
+
if (opened === TIMED_OUT) {
|
|
195
|
+
room.abandoned = true;
|
|
196
|
+
if (room.client) room.client.disconnect().catch(() => {});
|
|
197
|
+
return onWeb('start', 'timeout');
|
|
198
|
+
}
|
|
199
|
+
if (!opened.ok) return opened;
|
|
200
|
+
interviewSessions.set(opened.interviewId, { client: opened.client, buffer: opened.buffer, connectAt: Date.now(), ended: opened.opening.ended === true });
|
|
201
|
+
track(() => funnel.viewed('interview', { interview_mode: 'text' }));
|
|
202
|
+
return { ok: true, interviewId: opened.interviewId, answerMode: answer.mode, greeting: opened.opening.text, ended: opened.opening.ended === true };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Creates the interview, joins its room and reads the greeting; `room` lets a timed-out caller close what opened late.
|
|
206
|
+
async function openRoom(args, room) {
|
|
207
|
+
const start = await flow.createAndStartLivekitOnboarding(resolved, {
|
|
208
|
+
candidateId: args.candidateId,
|
|
209
|
+
language: args.language || lang,
|
|
210
|
+
});
|
|
211
|
+
if (!start.ok) {
|
|
212
|
+
if (TIMEOUT_KINDS.has(start.reason)) return onWeb(start.step || 'start', start.reason);
|
|
213
|
+
const out = { ok: false, step: start.step || 'start', reason: start.reason };
|
|
214
|
+
return start.reason === 'interview-already-started' ? { ...out, next: 'open_web' } : out;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const closing = start.closingMessage;
|
|
218
|
+
const isClosing = closing ? (t) => typeof t === 'string' && t.includes(closing) : null;
|
|
219
|
+
const client = resolved.makeInterviewClient({ isClosing });
|
|
220
|
+
room.client = client;
|
|
221
|
+
const buffer = new TranscriptBuffer();
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
await client.connect(start.livekitUrl, start.token);
|
|
225
|
+
} catch (e) {
|
|
226
|
+
try { await client.disconnect(); } catch { /* nothing joined */ }
|
|
227
|
+
const kind = (e && e.kind) || 'connect-failed';
|
|
228
|
+
if (LIVEKIT_LOAD_FAILURES.has(kind)) reportLivekitFailure(kind, e);
|
|
229
|
+
// Whatever kept the room from opening here, the same interview runs on the web.
|
|
230
|
+
return onWeb('connect', kind);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
let opening;
|
|
234
|
+
try {
|
|
235
|
+
opening = await client.receiveTurn();
|
|
236
|
+
} catch (e) {
|
|
237
|
+
try { await client.disconnect(); } catch { /* room already gone */ }
|
|
238
|
+
return onWeb('greeting', (e && e.kind) || 'no-greeting');
|
|
239
|
+
}
|
|
240
|
+
if (room.abandoned) {
|
|
241
|
+
try { await client.disconnect(); } catch { /* room already gone */ }
|
|
242
|
+
return { ok: false, reason: 'timeout' };
|
|
243
|
+
}
|
|
244
|
+
buffer.recordAgent(opening.text);
|
|
245
|
+
return { ok: true, interviewId: start.interviewId, client, buffer, opening };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function interviewTurn(args = {}) {
|
|
249
|
+
const sess = interviewSessions.get(args.interviewId);
|
|
250
|
+
if (!sess) {
|
|
251
|
+
return { ok: false, reason: 'unknown-interview', message: 'No live interview for that interviewId (the server may have restarted). Start again with onboarding_interview_start.' };
|
|
252
|
+
}
|
|
253
|
+
if (sess.ended) return { ok: true, response: '', ended: true };
|
|
254
|
+
|
|
255
|
+
const message = typeof args.message === 'string' ? args.message : '';
|
|
256
|
+
sess.buffer.recordUser(message);
|
|
257
|
+
let res;
|
|
258
|
+
try {
|
|
259
|
+
res = await withTimeout(sess.client.sendTurn(message), interviewTimeoutMs);
|
|
260
|
+
} catch (e) {
|
|
261
|
+
res = { failed: (e && e.kind) || 'turn-error' };
|
|
262
|
+
}
|
|
263
|
+
if (res === TIMED_OUT || res.failed) {
|
|
264
|
+
const kind = res === TIMED_OUT ? 'timeout' : res.failed;
|
|
265
|
+
const teardown = await withTimeout(teardownInterview(args.interviewId, { persist: true }), Math.min(interviewTimeoutMs, 15000));
|
|
266
|
+
const saved = teardown !== TIMED_OUT && teardown.ok === true;
|
|
267
|
+
if (TIMEOUT_KINDS.has(kind)) return { ...onWeb('turn', kind), saved };
|
|
268
|
+
return {
|
|
269
|
+
ok: false,
|
|
270
|
+
reason: kind,
|
|
271
|
+
saved,
|
|
272
|
+
message: `The interview connection failed; ${saved ? 'the transcript so far was saved' : 'the transcript so far could not be saved'}. Start again with onboarding_interview_start.`,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
sess.buffer.recordAgent(res.text);
|
|
276
|
+
if (res.ended === true) sess.ended = true;
|
|
277
|
+
return { ok: true, response: res.text, ended: res.ended === true };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function interviewComplete(args = {}) {
|
|
281
|
+
if (!interviewSessions.has(args.interviewId)) {
|
|
282
|
+
return { ok: false, reason: 'unknown-interview', message: 'No live interview for that interviewId (the server may have restarted). Nothing to complete; start again if the talent still wants the interview.' };
|
|
283
|
+
}
|
|
284
|
+
const saved = await teardownInterview(args.interviewId, { persist: true });
|
|
285
|
+
if (!saved.ok) return { ok: false, reason: saved.reason || 'complete-failed' };
|
|
286
|
+
lastCompletedInterview = { interviewId: args.interviewId, completedAt: Date.now() };
|
|
287
|
+
track(() => funnel.completed('interview', { interview_mode: 'text' }));
|
|
288
|
+
return { ok: true, state: 'taken', message: catalog.interviewComplete };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// OVERWRITE-repeat: reset the taken onboarding interview so it can be re-run.
|
|
292
|
+
async function repeatOnboarding() {
|
|
293
|
+
const reset = await flow.restartOnboarding(resolved);
|
|
294
|
+
if (!reset.ok) {
|
|
295
|
+
if (reset.reason === 'onboarding-not-found') {
|
|
296
|
+
return { ok: false, reason: 'onboarding-not-found', message: 'The talent has no onboarding interview yet: nothing to repeat. Offer onboarding_interview_start instead.' };
|
|
297
|
+
}
|
|
298
|
+
return { ok: false, reason: reset.reason };
|
|
299
|
+
}
|
|
300
|
+
return {
|
|
301
|
+
ok: true,
|
|
302
|
+
interviewId: reset.interviewId,
|
|
303
|
+
language: reset.language,
|
|
304
|
+
next: 'onboarding_interview_start',
|
|
305
|
+
message: 'Onboarding reset — the previous interview was overwritten. Now run it again with onboarding_interview_start; do NOT run the main-role step (that belongs to first-time registration) — go straight to onboarding_finish.',
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function onboardingFinish() {
|
|
310
|
+
const fin = await flow.completeOnboardingRegistration(resolved);
|
|
311
|
+
if (!fin.ok) return { ok: false, finalized: false, reason: fin.reason, profileUrl: flow.profileUrl(resolved) };
|
|
312
|
+
const pct = fin.completedProfilePercentage;
|
|
313
|
+
return {
|
|
314
|
+
ok: true,
|
|
315
|
+
finalized: true,
|
|
316
|
+
message: typeof pct === 'number' ? catalog.finalizedAt(pct) : catalog.finalizedGeneric,
|
|
317
|
+
onboardingStatus: fin.onboardingStatus,
|
|
318
|
+
registrationLevel: fin.registrationLevel,
|
|
319
|
+
completedProfilePercentage: pct,
|
|
320
|
+
profileUrl: flow.profileUrl(resolved),
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Content-free context the model may PROPOSE to the talent (never auto-write), searched only after the talent says yes.
|
|
325
|
+
async function suggestContext(args = {}, ctx = {}) {
|
|
326
|
+
const c = copy().consent;
|
|
327
|
+
const asked = await askConsent(ctx, { tool: 'suggest_register_context', key: 'suggest_register_context', question: c.cvSearch, yes: c.yes, no: c.no, answer: args.answer });
|
|
328
|
+
if (asked.reply) return asked.reply;
|
|
329
|
+
if (!asked.granted) return { ok: false, reason: 'consent-declined', message: 'The talent said no: nothing was searched. Do not look for the CV on this computer; ask them to attach it in the chat.' };
|
|
330
|
+
const read = deps.readRegisterContextSuggestions || require('./register-context').readRegisterContextSuggestions;
|
|
331
|
+
const suggestions = read({ talentName: args.talentName });
|
|
332
|
+
const found = suggestions.cvCandidates && suggestions.cvCandidates.length > 0;
|
|
333
|
+
return {
|
|
334
|
+
ok: true,
|
|
335
|
+
suggestions,
|
|
336
|
+
note: 'Propose these to the talent to confirm; never write them without confirmation.',
|
|
337
|
+
message: found
|
|
338
|
+
? 'Now call read_cv on the first candidate (the most likely), with talentName if you know it: read_cv asks the talent itself before reading it, so ask nothing in your own words. nameMatch:null only means their name is unknown, never that the CV is not theirs. Move to the next candidate only if read_cv says mentionsTalent:false; say you did not find it only when none is left.'
|
|
339
|
+
: 'No CV was found on this machine: ask the talent to attach it.',
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Text of the CV file the talent allowed this tool to read, in its own question.
|
|
344
|
+
async function readCv(args = {}, ctx = {}) {
|
|
345
|
+
if (typeof args.path !== 'string' || !args.path.trim()) return { ok: false, reason: 'cv-not-found', message: 'Pass the path of the CV file.' };
|
|
346
|
+
const file = path.resolve(expandHome(args.path.trim()));
|
|
347
|
+
const c = copy().consent;
|
|
348
|
+
const asked = await askConsent(ctx, { tool: 'read_cv', key: `read_cv:${file}`, question: c.cvRead(tildify(file)), yes: c.yes, no: c.no, answer: args.answer });
|
|
349
|
+
if (asked.reply) return asked.reply;
|
|
350
|
+
if (!asked.granted) return { ok: false, reason: 'consent-declined', message: 'The talent said no: the file was not read. Do not read it; ask them to attach their CV in the chat or tell you what it says.' };
|
|
351
|
+
const read = deps.readCvText || require('./cv-text').readCvText;
|
|
352
|
+
const result = read(file, { talentName: args.talentName });
|
|
353
|
+
if (!result.ok) return { ...result, message: 'Could not read the CV text here. Ask the talent to attach the CV to the chat, or to tell you what it says.' };
|
|
354
|
+
// Someone else's CV (a candidate they downloaded): never hand its contents over.
|
|
355
|
+
if (result.mentionsTalent === false) return { ok: true, mentionsTalent: false, note: "This CV does not name the talent: it belongs to someone else. Do not use, quote or describe it; try the next candidate." };
|
|
356
|
+
return result;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return [
|
|
360
|
+
{
|
|
361
|
+
name: 'suggest_register_context',
|
|
362
|
+
title: 'Look for your CV on this computer',
|
|
363
|
+
description: 'Asks you first, then lists the names of PDF and Word files in your Desktop, Documents, Downloads and iCloud Drive folders that look like a CV and reads your time zone and the name, email and GitHub user saved by your coding tools. It does not open those files or send anything.',
|
|
364
|
+
guidance: "SUGGESTIONS for the sign-up read on this machine (no network, no file contents): timezone, git name/email, the gh CLI GitHub login, and cvCandidates: PDF/DOC/DOCX files in Desktop, Documents, Downloads and iCloud Drive whose NAME looks like a CV or carries the talent's name, each with nameMatch (null when their name is unknown) and generic flags. A candidate is only a lead (people who hire keep other people's CVs): confirm it with read_cv. Pass talentName to sharpen the match. The tool asks the talent first (a dialog when the client has one, otherwise it returns `say` to print word for word as your whole reply); nothing is searched until they answer yes. After a `say`, call it again with answer set to the option they pick. Never answer for them.",
|
|
365
|
+
inputSchema: { type: 'object', properties: { talentName: { type: 'string', description: "The talent's full name as you know it (memory, chat); improves CV matching." }, answer: { type: 'string', description: 'The option the talent picked when you printed the question this tool returned. Never set it yourself.' } } },
|
|
366
|
+
handler: suggestContext,
|
|
367
|
+
},
|
|
368
|
+
{
|
|
369
|
+
name: 'read_cv',
|
|
370
|
+
title: 'Read your CV',
|
|
371
|
+
description: 'Asks you first, then reads the text of the CV file you allow to prepare your profile draft. This step sends nothing to Shakers.',
|
|
372
|
+
guidance: "Read the TEXT of a CV file (PDF, DOC, DOCX) on this machine, with talentName set. The tool asks the talent first, naming the file (a dialog when the client has one, otherwise it returns `say` to print word for word as your whole reply); nothing is read until they answer yes. After a `say`, call it again with the same path and answer set to the option they pick. Never answer for them. Returns { mentionsTalent, links: { linkedin, github, emails, phones }, text } for the talent's own CV; a CV that does not name them returns only mentionsTalent:false: it is someone else's, drop it, never describe it, try the next candidate. Nothing is uploaded by this tool: pass the confirmed path to signup_start (or import_profile) as cvPath, use the text for userQuery and the email, and links.linkedin as their LinkedIn URL.",
|
|
373
|
+
inputSchema: {
|
|
374
|
+
type: 'object',
|
|
375
|
+
properties: {
|
|
376
|
+
path: { type: 'string', description: 'Path to the CV file, exactly as suggest_register_context gave it (the short ~/ form is fine) or absolute.' },
|
|
377
|
+
answer: { type: 'string', description: 'The option the talent picked when you printed the question this tool returned. Never set it yourself.' },
|
|
378
|
+
talentName: { type: 'string', description: "The talent's full name as you know it; used to tell their CV from other people's." },
|
|
379
|
+
},
|
|
380
|
+
required: ['path'],
|
|
381
|
+
},
|
|
382
|
+
handler: readCv,
|
|
383
|
+
},
|
|
384
|
+
{
|
|
385
|
+
name: 'onboarding_interview_start',
|
|
386
|
+
title: 'Start your onboarding interview',
|
|
387
|
+
description: 'Starts the short Shakers interview here in the chat, after asking you where and how you want to answer it. Your answers go to Shakers to get to know your profile.',
|
|
388
|
+
guidance: "Start the onboarding interview, here in the chat. Offer it only when signup_status says onboardingInterview.completed is not true. Call it first without disclaimerAcknowledged: it asks 'here now or later on the web?' (in a dialog when the client has one, otherwise it returns `say` to print word for word; default here), then how they want to answer, with the two interview disclaimers in that same question. reason 'interview-later' means they chose the web: skip the interview. reason 'interview-on-web' (a sign-up language the chat interview does not speak) is the same: print relayVerbatim and skip it. If it returns reason 'interview-already-started' (begun elsewhere and not finished), call open_web so they continue on the web. It runs over LiveKit and the server agent conducts it; the client does not decide its branch. When it returns `say`, print it word for word as your whole reply (it holds the disclaimers and the question) and, once they answer, call with where:'here', disclaimerAcknowledged:true and answerMode. Returns { interviewId, answerMode, greeting, ended }: relay the greeting VERBATIM, then drive it with onboarding_interview_turn. ANSWER MODES. For drafted and full, first gather everything you know (memory, a search of past conversations if you have that tool, their CV, the ai_usage report, the sign-up data) and show ONE short sheet 'esto es lo que se de ti' (roles and projects with concrete facts, stack, languages, what they want next); let them correct it once; those corrections override your memory. Your answers are first person, one or two short sentences in their plain voice, built only from facts on the confirmed sheet: no embellishment, no adjectives, metrics or claims they did not give, no repeating a fact already said unless asked; for wishes, preferences or opinions give your best guess and say it is a guess. (1) drafted: show each question VERBATIM with your proposed answer and send it only after the talent approves or edits it; if the sheet does not cover a question, ask them briefly instead of guessing. Only if the talent explicitly asks you to answer everything yourself (for example 'hazla entera' or 'contéstalas tú todas'), answer the remaining questions without asking for each approval, as in full. (2) full (they chose it, or asked for it as above): answer every question yourself without per-answer approval, still showing each question and your answer, and when the interview ends tell them in one line that you answered those questions yourself. (3) own: you are a pure CONDUIT: show every question VERBATIM and pass back only their REAL, VERBATIM words. In every mode never reword, summarize or translate the questions. `interviewId` is internal: never show it. If it fails with next:'open_web', print relayVerbatim word for word and call open_web so they do the interview on the web.",
|
|
389
|
+
inputSchema: INTERVIEW_START_SCHEMA,
|
|
390
|
+
handler: interviewStart,
|
|
391
|
+
available: async () => {
|
|
392
|
+
try {
|
|
393
|
+
return !(await checkCompleted({ timeoutMs: 3000 }));
|
|
394
|
+
} catch {
|
|
395
|
+
return true;
|
|
396
|
+
}
|
|
397
|
+
},
|
|
398
|
+
},
|
|
399
|
+
{
|
|
400
|
+
name: 'onboarding_interview_turn',
|
|
401
|
+
title: 'Answer an interview question',
|
|
402
|
+
description: 'Sends the answer you gave or approved for the current interview question to Shakers and brings the next one.',
|
|
403
|
+
guidance: "Send the talent's answer to the current onboarding-interview question and get the next one. Follow the answerMode the talent chose at onboarding_interview_start. drafted: `message` is the answer the talent APPROVED or edited after seeing your proposal; never send one they have not confirmed, unless they explicitly asked you to answer the rest yourself ('hazla entera', 'contéstalas tú todas'): from then on, as in full, send your own answers without per-answer approval. full: send your own answer to each question, built only from what they confirmed, and when it ends tell them you answered those questions yourself. own: `message` is ONLY the talent's REAL, VERBATIM answer — never invent, infer or complete it. Outside full, if the talent has not answered or approved yet, do NOT call this, wait for them. Relay the returned `response` (the next question) to the talent VERBATIM — do not reword, summarize or translate it. Keep calling with their next `message` until `ended` is true, then call onboarding_interview_complete. Do NOT show or mention `interviewId` to the talent (internal handle). If it returns reason 'unknown-interview' (the server was restarted and the live room is gone), start again with onboarding_interview_start.",
|
|
404
|
+
inputSchema: INTERVIEW_TURN_SCHEMA,
|
|
405
|
+
handler: interviewTurn,
|
|
406
|
+
},
|
|
407
|
+
{
|
|
408
|
+
name: 'onboarding_interview_complete',
|
|
409
|
+
title: 'Finish the interview',
|
|
410
|
+
description: 'Closes the interview and saves it on Shakers.',
|
|
411
|
+
guidance: "Close the onboarding interview once the turns report ended:true: the buffered transcript is saved and the live room is disconnected. `interviewId` is internal: never show it. Returns { state:'taken' }. If it returns reason 'unknown-interview' the room was already gone (server restart): nothing to complete. In the sign-up flow, go on to the main role (list_my_roles with waitSeconds, then set_main_role) and finish with open_web; do NOT call onboarding_finish there.",
|
|
412
|
+
inputSchema: INTERVIEW_COMPLETE_SCHEMA,
|
|
413
|
+
handler: interviewComplete,
|
|
414
|
+
},
|
|
415
|
+
{
|
|
416
|
+
name: 'repeat_onboarding_interview',
|
|
417
|
+
title: 'Repeat your onboarding interview',
|
|
418
|
+
description: 'Deletes your previous onboarding interview on Shakers so you can take it again. This cannot be undone.',
|
|
419
|
+
guidance: "OVERWRITE-repeat the onboarding interview: resets the talent's completed onboarding interview IN PLACE; the previous evaluation is DISCARDED and CANNOT be recovered. Clearly warn the talent that this is irreversible and get their explicit go-ahead BEFORE calling this. On success run the interview again (onboarding_interview_start, onboarding_interview_turn until ended, onboarding_interview_complete), then onboarding_finish. Returns { interviewId, language } or reason 'onboarding-not-found' (no onboarding yet), 'http-401'/'http-403'.",
|
|
420
|
+
inputSchema: { type: 'object', properties: {} },
|
|
421
|
+
handler: repeatOnboarding,
|
|
422
|
+
},
|
|
423
|
+
{
|
|
424
|
+
name: 'onboarding_finish',
|
|
425
|
+
title: 'Complete your onboarding',
|
|
426
|
+
description: 'Marks your onboarding as completed on Shakers after you repeat the interview.',
|
|
427
|
+
guidance: "Mark the onboarding as COMPLETED on Shakers. NOT part of the sign-up flow (signup_start): there, never call it. Use it only to close a repeat_onboarding_interview run. Idempotent. Returns { finalized, onboardingStatus, registrationLevel, completedProfilePercentage, profileUrl }.",
|
|
428
|
+
inputSchema: FINISH_SCHEMA,
|
|
429
|
+
handler: onboardingFinish,
|
|
430
|
+
},
|
|
431
|
+
];
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
module.exports = {
|
|
435
|
+
makeRegisterTools,
|
|
436
|
+
completedOnboardingInterview,
|
|
437
|
+
INTERVIEW_START_SCHEMA,
|
|
438
|
+
INTERVIEW_TURN_SCHEMA,
|
|
439
|
+
INTERVIEW_COMPLETE_SCHEMA,
|
|
440
|
+
FINISH_SCHEMA,
|
|
441
|
+
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const REPORT_SCHEMA = {
|
|
4
|
+
type: 'object',
|
|
5
|
+
properties: {
|
|
6
|
+
root: { type: 'string', description: 'Repository root whose report to materialize. Defaults to the server working directory. Ignored when stored is true.' },
|
|
7
|
+
lang: { type: 'string', enum: ['es', 'en'] },
|
|
8
|
+
stored: { type: 'boolean', description: "When true, return the talent's ENTIRE stored server report (inventory + activity + AI-fluency level + prompting/interaction level) as plain text, read from the certs endpoint WITHOUT re-running any scan. When false/omitted, build the local shareable HTML report and return its file path." },
|
|
9
|
+
},
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const SHARE_SCHEMA = {
|
|
13
|
+
type: 'object',
|
|
14
|
+
properties: {
|
|
15
|
+
root: { type: 'string', description: 'Repository root whose share card to generate. Defaults to the server working directory.' },
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function makeReportTools(deps = {}) {
|
|
20
|
+
const {
|
|
21
|
+
materializeProjectReport = require('./report-store').materializeProjectReport,
|
|
22
|
+
loadFootprint = require('./report-store').loadFootprint,
|
|
23
|
+
generateShareCard = require('./share-card').generateShareCard,
|
|
24
|
+
loadAuthSession = require('./auth-session-store').loadAuthSession,
|
|
25
|
+
sessionStatus = require('./auth-session-store').sessionStatus,
|
|
26
|
+
getUsageReportEndpoint = require('./config').getUsageReportEndpoint,
|
|
27
|
+
requestUsageReport = require('./report-view-client').requestUsageReport,
|
|
28
|
+
renderUsageReportText = require('./render-usage-report-text').renderUsageReportText,
|
|
29
|
+
renderFullReportText = require('./render-report-text').renderFullReportText,
|
|
30
|
+
detectFlowLang = require('./i18n').detectFlowLang,
|
|
31
|
+
} = deps;
|
|
32
|
+
|
|
33
|
+
const requireSession = () => {
|
|
34
|
+
const session = loadAuthSession();
|
|
35
|
+
if (sessionStatus(session) !== 'active') {
|
|
36
|
+
throw new Error('no active Shakers session — sign in with social_signin_start (or run `shakers login`) first.');
|
|
37
|
+
}
|
|
38
|
+
return session;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
async function storedReport(session, lang) {
|
|
42
|
+
const endpoint = getUsageReportEndpoint();
|
|
43
|
+
if (!endpoint) {
|
|
44
|
+
return { ok: false, reason: 'no-endpoint', message: 'no certs endpoint configured to read the stored report.' };
|
|
45
|
+
}
|
|
46
|
+
const res = await requestUsageReport({ accessToken: session.accessToken }, { endpoint });
|
|
47
|
+
if (!res.ok) {
|
|
48
|
+
return { ok: false, reason: res.reason || 'read-failed', message: `could not read the stored report (${res.reason || 'unknown'}).` };
|
|
49
|
+
}
|
|
50
|
+
if (!res.report) {
|
|
51
|
+
return { ok: true, hasReport: false, message: 'no stored report for this talent yet — run ai_usage first (and share the report).' };
|
|
52
|
+
}
|
|
53
|
+
return { ok: true, hasReport: true, text: renderUsageReportText(res, { lang }) };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function report(args = {}) {
|
|
57
|
+
const session = requireSession();
|
|
58
|
+
if (args.stored === true) {
|
|
59
|
+
return storedReport(session, args.lang || detectFlowLang());
|
|
60
|
+
}
|
|
61
|
+
const result = materializeProjectReport({ root: args.root || null, lang: args.lang || null });
|
|
62
|
+
if (!result.hasData) {
|
|
63
|
+
return { ok: false, reason: 'no-data', message: 'no report yet — run ai_usage (and optionally certify) for this repo first.' };
|
|
64
|
+
}
|
|
65
|
+
const ret = { ok: true, path: result.htmlPath, fileUrl: result.fileUrl };
|
|
66
|
+
const footprint = loadFootprint({ root: args.root || null });
|
|
67
|
+
if (footprint && footprint.report) {
|
|
68
|
+
const preview = footprint.report.aiWorkPreview;
|
|
69
|
+
const aiProfile = preview ? { status: 'ready', preview } : { status: 'unavailable', preview: null };
|
|
70
|
+
const text = renderFullReportText(footprint.report, footprint.maturity, aiProfile, { lang: args.lang || detectFlowLang() });
|
|
71
|
+
if (text) ret.text = text;
|
|
72
|
+
}
|
|
73
|
+
return ret;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function share(args = {}) {
|
|
77
|
+
requireSession();
|
|
78
|
+
const result = generateShareCard({ root: args.root || null });
|
|
79
|
+
if (!result.ok) {
|
|
80
|
+
return { ok: false, reason: 'no-footprint', message: 'no report yet — run ai_usage for this repo first.' };
|
|
81
|
+
}
|
|
82
|
+
return { ok: true, path: result.htmlPath, fileUrl: result.fileUrl };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return [
|
|
86
|
+
{
|
|
87
|
+
name: 'report',
|
|
88
|
+
title: 'Show your AI report',
|
|
89
|
+
description: 'Shows your AI report, either the one saved on Shakers or one built from your last AI analysis on this computer, which it also saves there as a file.',
|
|
90
|
+
guidance: "Two modes. stored:true returns the talent's ENTIRE stored server report (inventory + activity + AI-fluency level + prompting/interaction level) as plain text, read from certs WITHOUT re-running any scan — says so cleanly when none is stored (this stored view does NOT include the Setup×Usage matrix or the vision/how-I-work narratives). Otherwise builds the local report for this repo from local state: it returns the full human-readable report in `text` (Markdown mirroring the CLI terminal — Setup level and rank rationale, tools, MCP servers, agents, activity, plus the Setup×Usage matrix and vision/how-I-work when available) AND the HTML file path (no browser). Render `text` verbatim to the talent; keep the fenced blocks fenced so the tables stay aligned. Requires an active session. Any internal ids/codes are handles for tools only — never show or mention them to the talent.",
|
|
91
|
+
inputSchema: REPORT_SCHEMA,
|
|
92
|
+
handler: report,
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
name: 'share',
|
|
96
|
+
title: 'Create your share card',
|
|
97
|
+
description: 'Creates on this computer a card with your AI report to share on LinkedIn. It sends nothing.',
|
|
98
|
+
guidance: "Generate the Shakers-branded LinkedIn share card for this repo and return its file path. Does not open a browser. Requires an active session.",
|
|
99
|
+
inputSchema: SHARE_SCHEMA,
|
|
100
|
+
handler: share,
|
|
101
|
+
},
|
|
102
|
+
];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
module.exports = { makeReportTools, REPORT_SCHEMA, SHARE_SCHEMA };
|